Chat creation feature (#138)

This commit is contained in:
2025-03-23 07:33:58 +03:00
committed by GitHub
parent 36a119ffa9
commit 4cc6ec6b5d
51 changed files with 1120 additions and 120 deletions
+1
View File
@@ -77,6 +77,7 @@ dependencies {
implementation(projects.feature.friends) implementation(projects.feature.friends)
implementation(projects.feature.profile) implementation(projects.feature.profile)
implementation(projects.feature.photoviewer) implementation(projects.feature.photoviewer)
implementation(projects.feature.createchat)
implementation(projects.core.common) implementation(projects.core.common)
implementation(projects.core.ui) implementation(projects.core.ui)
@@ -16,6 +16,7 @@ import dev.meloda.fast.common.provider.Provider
import dev.meloda.fast.common.provider.ResourceProvider import dev.meloda.fast.common.provider.ResourceProvider
import dev.meloda.fast.common.provider.ResourceProviderImpl import dev.meloda.fast.common.provider.ResourceProviderImpl
import dev.meloda.fast.conversations.di.conversationsModule import dev.meloda.fast.conversations.di.conversationsModule
import dev.meloda.fast.conversations.di.createChatModule
import dev.meloda.fast.domain.di.domainModule import dev.meloda.fast.domain.di.domainModule
import dev.meloda.fast.friends.di.friendsModule import dev.meloda.fast.friends.di.friendsModule
import dev.meloda.fast.languagepicker.di.languagePickerModule import dev.meloda.fast.languagepicker.di.languagePickerModule
@@ -46,7 +47,8 @@ val applicationModule = module {
longPollModule, longPollModule,
friendsModule, friendsModule,
profileModule, profileModule,
chatMaterialsModule chatMaterialsModule,
createChatModule
) )
// TODO: 14/05/2024, Danil Nikolaev: research on memory leaks and potentials errors // TODO: 14/05/2024, Danil Nikolaev: research on memory leaks and potentials errors
@@ -25,6 +25,7 @@ fun NavGraphBuilder.mainScreen(
onConversationClicked: (conversationId: Int) -> Unit, onConversationClicked: (conversationId: Int) -> Unit,
onPhotoClicked: (url: String) -> Unit, onPhotoClicked: (url: String) -> Unit,
onMessageClicked: (userId: Int) -> Unit, onMessageClicked: (userId: Int) -> Unit,
onCreateChatClicked: () -> Unit,
viewModel: MainViewModel viewModel: MainViewModel
) { ) {
val navigationItems = ImmutableList.of( val navigationItems = ImmutableList.of(
@@ -56,6 +57,7 @@ fun NavGraphBuilder.mainScreen(
onConversationItemClicked = onConversationClicked, onConversationItemClicked = onConversationClicked,
onPhotoClicked = onPhotoClicked, onPhotoClicked = onPhotoClicked,
onMessageClicked = onMessageClicked, onMessageClicked = onMessageClicked,
onCreateChatClicked = onCreateChatClicked,
viewModel = viewModel viewModel = viewModel
) )
} }
@@ -57,6 +57,7 @@ fun MainScreen(
onConversationItemClicked: (conversationId: Int) -> Unit = {}, onConversationItemClicked: (conversationId: Int) -> Unit = {},
onPhotoClicked: (url: String) -> Unit = {}, onPhotoClicked: (url: String) -> Unit = {},
onMessageClicked: (userId: Int) -> Unit = {}, onMessageClicked: (userId: Int) -> Unit = {},
onCreateChatClicked: () -> Unit = {},
viewModel: MainViewModel viewModel: MainViewModel
) { ) {
val currentTheme = LocalThemeConfig.current val currentTheme = LocalThemeConfig.current
@@ -172,6 +173,7 @@ fun MainScreen(
onError = onError, onError = onError,
onConversationItemClicked = onConversationItemClicked, onConversationItemClicked = onConversationItemClicked,
onPhotoClicked = onPhotoClicked, onPhotoClicked = onPhotoClicked,
onCreateChatClicked = onCreateChatClicked,
navController = navController, navController = navController,
) )
profileScreen( profileScreen(
@@ -25,6 +25,8 @@ import dev.meloda.fast.auth.authNavGraph
import dev.meloda.fast.auth.navigateToAuth import dev.meloda.fast.auth.navigateToAuth
import dev.meloda.fast.chatmaterials.navigation.chatMaterialsScreen import dev.meloda.fast.chatmaterials.navigation.chatMaterialsScreen
import dev.meloda.fast.chatmaterials.navigation.navigateToChatMaterials import dev.meloda.fast.chatmaterials.navigation.navigateToChatMaterials
import dev.meloda.fast.conversations.navigation.createChatScreen
import dev.meloda.fast.conversations.navigation.navigateToCreateChat
import dev.meloda.fast.languagepicker.navigation.languagePickerScreen import dev.meloda.fast.languagepicker.navigation.languagePickerScreen
import dev.meloda.fast.languagepicker.navigation.navigateToLanguagePicker import dev.meloda.fast.languagepicker.navigation.navigateToLanguagePicker
import dev.meloda.fast.messageshistory.navigation.messagesHistoryScreen import dev.meloda.fast.messageshistory.navigation.messagesHistoryScreen
@@ -125,6 +127,7 @@ fun RootScreen(
onConversationClicked = navController::navigateToMessagesHistory, onConversationClicked = navController::navigateToMessagesHistory,
onPhotoClicked = { url -> navController.navigateToPhotoView(listOf(url)) }, onPhotoClicked = { url -> navController.navigateToPhotoView(listOf(url)) },
onMessageClicked = navController::navigateToMessagesHistory, onMessageClicked = navController::navigateToMessagesHistory,
onCreateChatClicked = navController::navigateToCreateChat,
viewModel = viewModel viewModel = viewModel
) )
@@ -137,6 +140,13 @@ fun RootScreen(
onBack = navController::navigateUp, onBack = navController::navigateUp,
onPhotoClicked = { url -> navController.navigateToPhotoView(listOf(url)) } onPhotoClicked = { url -> navController.navigateToPhotoView(listOf(url)) }
) )
createChatScreen(
onChatCreated = { conversationId ->
navController.popBackStack()
navController.navigateToMessagesHistory(conversationId)
},
navController = navController
)
settingsScreen( settingsScreen(
onBack = navController::navigateUp, onBack = navController::navigateUp,
@@ -1,10 +1,10 @@
package dev.meloda.fast.data.api.messages package dev.meloda.fast.data.api.messages
import com.slack.eithernet.ApiResult
import dev.meloda.fast.model.api.domain.VkAttachment import dev.meloda.fast.model.api.domain.VkAttachment
import dev.meloda.fast.model.api.domain.VkAttachmentHistoryMessage import dev.meloda.fast.model.api.domain.VkAttachmentHistoryMessage
import dev.meloda.fast.model.api.domain.VkMessage import dev.meloda.fast.model.api.domain.VkMessage
import dev.meloda.fast.network.RestApiErrorDomain import dev.meloda.fast.network.RestApiErrorDomain
import com.slack.eithernet.ApiResult
interface MessagesRepository { interface MessagesRepository {
@@ -41,6 +41,11 @@ interface MessagesRepository {
conversationMessageId: Int conversationMessageId: Int
): ApiResult<List<VkAttachmentHistoryMessage>, RestApiErrorDomain> ): ApiResult<List<VkAttachmentHistoryMessage>, RestApiErrorDomain>
suspend fun createChat(
userIds: List<Int>?,
title: String?
): ApiResult<Int, RestApiErrorDomain>
suspend fun storeMessages(messages: List<VkMessage>) suspend fun storeMessages(messages: List<VkMessage>)
// suspend fun markAsImportant( // suspend fun markAsImportant(
@@ -1,5 +1,6 @@
package dev.meloda.fast.data.api.messages package dev.meloda.fast.data.api.messages
import com.slack.eithernet.ApiResult
import dev.meloda.fast.common.VkConstants import dev.meloda.fast.common.VkConstants
import dev.meloda.fast.data.VkGroupsMap import dev.meloda.fast.data.VkGroupsMap
import dev.meloda.fast.data.VkMemoryCache import dev.meloda.fast.data.VkMemoryCache
@@ -14,6 +15,7 @@ import dev.meloda.fast.model.api.domain.VkAttachment
import dev.meloda.fast.model.api.domain.VkAttachmentHistoryMessage import dev.meloda.fast.model.api.domain.VkAttachmentHistoryMessage
import dev.meloda.fast.model.api.domain.VkMessage import dev.meloda.fast.model.api.domain.VkMessage
import dev.meloda.fast.model.api.domain.asEntity import dev.meloda.fast.model.api.domain.asEntity
import dev.meloda.fast.model.api.requests.MessagesCreateChatRequest
import dev.meloda.fast.model.api.requests.MessagesGetByIdRequest import dev.meloda.fast.model.api.requests.MessagesGetByIdRequest
import dev.meloda.fast.model.api.requests.MessagesGetHistoryAttachmentsRequest import dev.meloda.fast.model.api.requests.MessagesGetHistoryAttachmentsRequest
import dev.meloda.fast.model.api.requests.MessagesGetHistoryRequest import dev.meloda.fast.model.api.requests.MessagesGetHistoryRequest
@@ -23,7 +25,6 @@ import dev.meloda.fast.network.RestApiErrorDomain
import dev.meloda.fast.network.mapApiDefault import dev.meloda.fast.network.mapApiDefault
import dev.meloda.fast.network.mapApiResult import dev.meloda.fast.network.mapApiResult
import dev.meloda.fast.network.service.messages.MessagesService import dev.meloda.fast.network.service.messages.MessagesService
import com.slack.eithernet.ApiResult
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
@@ -198,6 +199,23 @@ class MessagesRepositoryImpl(
) )
} }
override suspend fun createChat(
userIds: List<Int>?,
title: String?
): ApiResult<Int, RestApiErrorDomain> = withContext(Dispatchers.IO) {
val requestModel = MessagesCreateChatRequest(
userIds = userIds,
title = title
)
messagesService.createChat(requestModel.map).mapApiResult(
successMapper = { apiResponse ->
apiResponse.requireResponse().chatId
},
errorMapper = { error -> error?.toDomain() }
)
}
override suspend fun storeMessages(messages: List<VkMessage>) { override suspend fun storeMessages(messages: List<VkMessage>) {
messageDao.insertAll(messages.map(VkMessage::asEntity)) messageDao.insertAll(messages.map(VkMessage::asEntity))
} }
@@ -20,4 +20,8 @@ class GetLocalUserByIdUseCase(private val repository: UsersRepository) {
emit(newState) emit(newState)
} }
suspend fun proceed(userId: Int): VkUser? {
return repository.getLocalUsers(userIds = listOf(userId)).singleOrNull()
}
} }
@@ -42,6 +42,11 @@ interface MessagesUseCase {
conversationMessageId: Int conversationMessageId: Int
): Flow<State<List<VkAttachmentHistoryMessage>>> ): Flow<State<List<VkAttachmentHistoryMessage>>>
fun createChat(
userIds: List<Int>?,
title: String?
): Flow<State<Int>>
suspend fun storeMessage(message: VkMessage) suspend fun storeMessage(message: VkMessage)
suspend fun storeMessages(messages: List<VkMessage>) suspend fun storeMessages(messages: List<VkMessage>)
} }
@@ -100,6 +100,14 @@ class MessagesUseCaseImpl(
emit(newState) emit(newState)
} }
override fun createChat(userIds: List<Int>?, title: String?): Flow<State<Int>> = flow {
emit(State.Loading)
val newState = repository.createChat(userIds, title).mapToState()
emit(newState)
}
override suspend fun storeMessage(message: VkMessage) { override suspend fun storeMessage(message: VkMessage) {
repository.storeMessages(listOf(message)) repository.storeMessages(listOf(message))
} }
@@ -1,9 +1,9 @@
package dev.meloda.fast.friends.util package dev.meloda.fast.domain.util
import dev.meloda.fast.common.model.UiImage import dev.meloda.fast.common.model.UiImage
import dev.meloda.fast.data.VkMemoryCache import dev.meloda.fast.data.VkMemoryCache
import dev.meloda.fast.friends.model.UiFriend
import dev.meloda.fast.model.api.domain.VkUser import dev.meloda.fast.model.api.domain.VkUser
import dev.meloda.fast.ui.model.api.UiFriend
fun VkUser.asPresentation( fun VkUser.asPresentation(
useContactNames: Boolean = false useContactNames: Boolean = false
@@ -16,5 +16,7 @@ fun VkUser.asPresentation(
fullName fullName
}, },
onlineStatus = onlineStatus, onlineStatus = onlineStatus,
photo400Orig = photo400Orig?.let(UiImage::Url) photo400Orig = photo400Orig?.let(UiImage::Url),
firstName = firstName,
lastName = lastName
) )
@@ -267,3 +267,14 @@ data class MessagesGetHistoryAttachmentsRequest(
fields?.let { this["fields"] = it } fields?.let { this["fields"] = it }
} }
} }
data class MessagesCreateChatRequest(
val userIds: List<Int>?,
val title: String?
) {
val map = mutableMapOf<String, String>().apply {
userIds?.let { this["user_ids"] = it.joinToString(",") }
title?.let { this["title"] = it }
}
}
@@ -1,5 +1,7 @@
package dev.meloda.fast.model.api.responses package dev.meloda.fast.model.api.responses
import com.squareup.moshi.Json
import com.squareup.moshi.JsonClass
import dev.meloda.fast.model.api.data.VkAttachmentHistoryMessageData import dev.meloda.fast.model.api.data.VkAttachmentHistoryMessageData
import dev.meloda.fast.model.api.data.VkChatMemberData import dev.meloda.fast.model.api.data.VkChatMemberData
import dev.meloda.fast.model.api.data.VkContactData import dev.meloda.fast.model.api.data.VkContactData
@@ -7,8 +9,6 @@ import dev.meloda.fast.model.api.data.VkConversationData
import dev.meloda.fast.model.api.data.VkGroupData import dev.meloda.fast.model.api.data.VkGroupData
import dev.meloda.fast.model.api.data.VkMessageData import dev.meloda.fast.model.api.data.VkMessageData
import dev.meloda.fast.model.api.data.VkUserData import dev.meloda.fast.model.api.data.VkUserData
import com.squareup.moshi.Json
import com.squareup.moshi.JsonClass
@JsonClass(generateAdapter = true) @JsonClass(generateAdapter = true)
data class MessagesGetHistoryResponse( data class MessagesGetHistoryResponse(
@@ -44,3 +44,9 @@ data class MessagesGetHistoryAttachmentsResponse(
@Json(name = "groups") val groups: List<VkGroupData>?, @Json(name = "groups") val groups: List<VkGroupData>?,
@Json(name = "contacts") val contacts: List<VkContactData>? @Json(name = "contacts") val contacts: List<VkContactData>?
) )
@JsonClass(generateAdapter = true)
data class MessagesCreateChatResponse(
@Json(name = "chat_id") val chatId: Int,
@Json(name = "peer_ids") val peerIds: List<Int>
)
@@ -1,12 +1,13 @@
package dev.meloda.fast.network.service.messages package dev.meloda.fast.network.service.messages
import com.slack.eithernet.ApiResult
import dev.meloda.fast.model.api.data.VkLongPollData import dev.meloda.fast.model.api.data.VkLongPollData
import dev.meloda.fast.model.api.responses.MessagesCreateChatResponse
import dev.meloda.fast.model.api.responses.MessagesGetByIdResponse import dev.meloda.fast.model.api.responses.MessagesGetByIdResponse
import dev.meloda.fast.model.api.responses.MessagesGetHistoryAttachmentsResponse import dev.meloda.fast.model.api.responses.MessagesGetHistoryAttachmentsResponse
import dev.meloda.fast.model.api.responses.MessagesGetHistoryResponse import dev.meloda.fast.model.api.responses.MessagesGetHistoryResponse
import dev.meloda.fast.network.ApiResponse import dev.meloda.fast.network.ApiResponse
import dev.meloda.fast.network.RestApiError import dev.meloda.fast.network.RestApiError
import com.slack.eithernet.ApiResult
import retrofit2.http.FieldMap import retrofit2.http.FieldMap
import retrofit2.http.FormUrlEncoded import retrofit2.http.FormUrlEncoded
import retrofit2.http.POST import retrofit2.http.POST
@@ -49,6 +50,12 @@ interface MessagesService {
@FieldMap params: Map<String, String> @FieldMap params: Map<String, String>
): ApiResult<ApiResponse<MessagesGetHistoryAttachmentsResponse>, RestApiError> ): ApiResult<ApiResponse<MessagesGetHistoryAttachmentsResponse>, RestApiError>
@FormUrlEncoded
@POST(MessagesUrls.CREATE_CHAT)
suspend fun createChat(
@FieldMap params: Map<String, String>
): ApiResult<ApiResponse<MessagesCreateChatResponse>, RestApiError>
// @FormUrlEncoded // @FormUrlEncoded
// @POST(MessagesUrls.MarkAsImportant) // @POST(MessagesUrls.MarkAsImportant)
// suspend fun markAsImportant( // suspend fun markAsImportant(
@@ -19,4 +19,5 @@ object MessagesUrls {
const val GET_CONVERSATIONS_MEMBERS = "${AppConstants.URL_API}/messages.getConversationMembers" const val GET_CONVERSATIONS_MEMBERS = "${AppConstants.URL_API}/messages.getConversationMembers"
const val REMOVE_CHAT_USER = "${AppConstants.URL_API}/messages.removeChatUser" const val REMOVE_CHAT_USER = "${AppConstants.URL_API}/messages.removeChatUser"
const val GET_HISTORY_ATTACHMENTS = "${AppConstants.URL_API}/messages.getHistoryAttachments" const val GET_HISTORY_ATTACHMENTS = "${AppConstants.URL_API}/messages.getHistoryAttachments"
const val CREATE_CHAT = "${AppConstants.URL_API}/messages.createChat"
} }
+1
View File
@@ -0,0 +1 @@
/build
+12
View File
@@ -0,0 +1,12 @@
plugins {
alias(libs.plugins.fast.android.library)
alias(libs.plugins.fast.android.library.compose)
alias(libs.plugins.kotlin.serialization)
}
android {
namespace = "dev.meloda.fast.presentation"
}
dependencies {
}
@@ -0,0 +1,4 @@
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
</manifest>
+1
View File
@@ -11,6 +11,7 @@ android {
dependencies { dependencies {
api(projects.core.common) api(projects.core.common)
api(projects.core.model) api(projects.core.model)
implementation(projects.core.presentation)
implementation(libs.haze) implementation(libs.haze)
implementation(libs.haze.materials) implementation(libs.haze.materials)
@@ -1,4 +1,4 @@
package dev.meloda.fast.conversations.model package dev.meloda.fast.ui.model.api
enum class ActionState { enum class ActionState {
PHANTOM, CALL_IN_PROGRESS, NONE; PHANTOM, CALL_IN_PROGRESS, NONE;
@@ -0,0 +1,31 @@
package dev.meloda.fast.ui.model.api
import dev.meloda.fast.common.model.UiImage
import dev.meloda.fast.common.model.UiText
import dev.meloda.fast.ui.R
sealed class ConversationOption(
val title: UiText,
val icon: UiImage
) {
data object MarkAsRead : ConversationOption(
title = UiText.Resource(R.string.action_mark_as_read),
icon = UiImage.Resource(R.drawable.round_done_all_24)
)
data object Pin : ConversationOption(
title = UiText.Resource(R.string.action_pin),
icon = UiImage.Resource(R.drawable.pin_outline_24)
)
data object Unpin : ConversationOption(
title = UiText.Resource(R.string.action_unpin),
icon = UiImage.Resource(R.drawable.pin_off_outline_24)
)
data object Delete : ConversationOption(
title = UiText.Resource(R.string.action_delete),
icon = UiImage.Resource(R.drawable.round_delete_outline_24)
)
}
@@ -1,4 +1,4 @@
package dev.meloda.fast.conversations.model package dev.meloda.fast.ui.model.api
data class ConversationsShowOptions( data class ConversationsShowOptions(
val showDeleteDialog: Int?, val showDeleteDialog: Int?,
@@ -1,4 +1,4 @@
package dev.meloda.fast.conversations.model package dev.meloda.fast.ui.model.api
import androidx.compose.runtime.Immutable import androidx.compose.runtime.Immutable
import androidx.compose.ui.text.AnnotatedString import androidx.compose.ui.text.AnnotatedString
@@ -1,11 +1,15 @@
package dev.meloda.fast.friends.model package dev.meloda.fast.ui.model.api
import androidx.compose.runtime.Immutable
import dev.meloda.fast.common.model.UiImage import dev.meloda.fast.common.model.UiImage
import dev.meloda.fast.model.api.domain.OnlineStatus import dev.meloda.fast.model.api.domain.OnlineStatus
@Immutable
data class UiFriend( data class UiFriend(
val userId: Int, val userId: Int,
val avatar: UiImage?, val avatar: UiImage?,
val firstName: String,
val lastName: String,
val title: String, val title: String,
val onlineStatus: OnlineStatus, val onlineStatus: OnlineStatus,
val photo400Orig: UiImage? val photo400Orig: UiImage?
@@ -215,4 +215,7 @@
<string name="no_online_friends">Никого в сети</string> <string name="no_online_friends">Никого в сети</string>
<string name="try_again">Попробовать ещё раз</string> <string name="try_again">Попробовать ещё раз</string>
<string name="session_expired">Срок действия сессии истёк</string> <string name="session_expired">Срок действия сессии истёк</string>
<string name="title_create_chat">Создать чат</string>
<string name="action_create">Создать</string>
<string name="create_chat_title">Название</string>
</resources> </resources>
+3
View File
@@ -280,4 +280,7 @@
<string name="no_online_friends">No one is online</string> <string name="no_online_friends">No one is online</string>
<string name="try_again">Try again</string> <string name="try_again">Try again</string>
<string name="session_expired">Session expired</string> <string name="session_expired">Session expired</string>
<string name="title_create_chat">Create chat</string>
<string name="action_create">Create</string>
<string name="create_chat_title">Title</string>
</resources> </resources>
@@ -11,10 +11,7 @@ import dev.meloda.fast.common.extensions.createTimerFlow
import dev.meloda.fast.common.extensions.findWithIndex import dev.meloda.fast.common.extensions.findWithIndex
import dev.meloda.fast.common.extensions.listenValue 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.conversations.model.ConversationOption
import dev.meloda.fast.conversations.model.ConversationsScreenState import dev.meloda.fast.conversations.model.ConversationsScreenState
import dev.meloda.fast.conversations.model.ConversationsShowOptions
import dev.meloda.fast.conversations.model.UiConversation
import dev.meloda.fast.conversations.util.asPresentation import dev.meloda.fast.conversations.util.asPresentation
import dev.meloda.fast.conversations.util.extractAvatar import dev.meloda.fast.conversations.util.extractAvatar
import dev.meloda.fast.data.State import dev.meloda.fast.data.State
@@ -29,6 +26,9 @@ import dev.meloda.fast.model.InteractionType
import dev.meloda.fast.model.LongPollEvent import dev.meloda.fast.model.LongPollEvent
import dev.meloda.fast.model.api.domain.VkConversation import dev.meloda.fast.model.api.domain.VkConversation
import dev.meloda.fast.network.VkErrorCode import dev.meloda.fast.network.VkErrorCode
import dev.meloda.fast.ui.model.api.ConversationOption
import dev.meloda.fast.ui.model.api.ConversationsShowOptions
import dev.meloda.fast.ui.model.api.UiConversation
import dev.meloda.fast.ui.util.ImmutableList import dev.meloda.fast.ui.util.ImmutableList
import kotlinx.coroutines.Job import kotlinx.coroutines.Job
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
@@ -84,10 +84,7 @@ class ConversationsViewModelImpl(
override val currentOffset = MutableStateFlow(0) override val currentOffset = MutableStateFlow(0)
override val canPaginate = MutableStateFlow(false) override val canPaginate = MutableStateFlow(false)
// TODO: 22-Dec-24, Danil Nikolaev: rewrite private val useContactNames: Boolean get() = userSettings.useContactNames.value
private val useContactNames = {
userSettings.useContactNames.value
}
override fun onPaginationConditionsMet() { override fun onPaginationConditionsMet() {
currentOffset.update { screenState.value.conversations.size } currentOffset.update { screenState.value.conversations.size }
@@ -168,11 +165,11 @@ class ConversationsViewModelImpl(
conversations = old.conversations.map { item -> conversations = old.conversations.map { item ->
item.copy( item.copy(
isExpanded = isExpanded =
if (item.id == conversation.id) { if (item.id == conversation.id) {
!item.isExpanded !item.isExpanded
} else { } else {
false false
}, },
options = ImmutableList.copyOf(options) options = ImmutableList.copyOf(options)
) )
} }
@@ -191,7 +188,10 @@ class ConversationsViewModelImpl(
onPinDialogDismissed() onPinDialogDismissed()
} }
override fun onOptionClicked(conversation: UiConversation, option: ConversationOption) { override fun onOptionClicked(
conversation: UiConversation,
option: ConversationOption
) {
when (option) { when (option) {
ConversationOption.Delete -> { ConversationOption.Delete -> {
emitShowOptions { old -> emitShowOptions { old ->
@@ -322,21 +322,25 @@ class ConversationsViewModelImpl(
} }
} }
} }
State.Error.ConnectionError -> { State.Error.ConnectionError -> {
baseError.setValue { baseError.setValue {
BaseError.SimpleError(message = "Connection error") BaseError.SimpleError(message = "Connection error")
} }
} }
State.Error.InternalError -> { State.Error.InternalError -> {
baseError.setValue { baseError.setValue {
BaseError.SimpleError(message = "Internal error") BaseError.SimpleError(message = "Internal error")
} }
} }
State.Error.UnknownError -> { State.Error.UnknownError -> {
baseError.setValue { baseError.setValue {
BaseError.SimpleError(message = "Unknown error") BaseError.SimpleError(message = "Unknown error")
} }
} }
else -> Unit else -> Unit
} }
} }
@@ -360,7 +364,7 @@ class ConversationsViewModelImpl(
conversations = newConversations.map { conversations = newConversations.map {
it.asPresentation( it.asPresentation(
resources = resources, resources = resources,
useContactName = useContactNames() useContactName = useContactNames
) )
} }
) )
@@ -424,7 +428,7 @@ class ConversationsViewModelImpl(
conversations = newConversations.map { conversations = newConversations.map {
it.asPresentation( it.asPresentation(
resources = resources, resources = resources,
useContactName = useContactNames() useContactName = useContactNames
) )
} }
) )
@@ -475,7 +479,7 @@ class ConversationsViewModelImpl(
conversations = newConversations.map { conversations = newConversations.map {
it.asPresentation( it.asPresentation(
resources = resources, resources = resources,
useContactName = useContactNames() useContactName = useContactNames
) )
} }
) )
@@ -504,7 +508,7 @@ class ConversationsViewModelImpl(
conversations = newConversations.map { conversations = newConversations.map {
it.asPresentation( it.asPresentation(
resources = resources, resources = resources,
useContactName = useContactNames() useContactName = useContactNames
) )
} }
) )
@@ -534,7 +538,7 @@ class ConversationsViewModelImpl(
conversations = newConversations.map { conversations = newConversations.map {
it.asPresentation( it.asPresentation(
resources = resources, resources = resources,
useContactName = useContactNames() useContactName = useContactNames
) )
} }
) )
@@ -563,7 +567,7 @@ class ConversationsViewModelImpl(
conversations = newConversations.map { conversations = newConversations.map {
it.asPresentation( it.asPresentation(
resources = resources, resources = resources,
useContactName = useContactNames() useContactName = useContactNames
) )
} }
) )
@@ -609,7 +613,7 @@ class ConversationsViewModelImpl(
old.copy(conversations = newConversations.map { old.copy(conversations = newConversations.map {
it.asPresentation( it.asPresentation(
resources = resources, resources = resources,
useContactName = useContactNames() useContactName = useContactNames
) )
}) })
} }
@@ -650,7 +654,7 @@ class ConversationsViewModelImpl(
conversations = newConversations.map { conversations = newConversations.map {
it.asPresentation( it.asPresentation(
resources = resources, resources = resources,
useContactName = useContactNames() useContactName = useContactNames
) )
} }
) )
@@ -701,7 +705,7 @@ class ConversationsViewModelImpl(
conversations = newConversations.map { conversations = newConversations.map {
it.asPresentation( it.asPresentation(
resources = resources, resources = resources,
useContactName = useContactNames() useContactName = useContactNames
) )
} }
) )
@@ -735,7 +739,7 @@ class ConversationsViewModelImpl(
conversations = newConversations.map { conversations = newConversations.map {
it.asPresentation( it.asPresentation(
resources = resources, resources = resources,
useContactName = useContactNames() useContactName = useContactNames
) )
} }
) )
@@ -1,31 +0,0 @@
package dev.meloda.fast.conversations.model
import dev.meloda.fast.common.model.UiImage
import dev.meloda.fast.common.model.UiText
import dev.meloda.fast.ui.R as UiR
sealed class ConversationOption(
val title: UiText,
val icon: UiImage
) {
data object MarkAsRead : ConversationOption(
title = UiText.Resource(UiR.string.action_mark_as_read),
icon = UiImage.Resource(UiR.drawable.round_done_all_24)
)
data object Pin : ConversationOption(
title = UiText.Resource(UiR.string.action_pin),
icon = UiImage.Resource(UiR.drawable.pin_outline_24)
)
data object Unpin : ConversationOption(
title = UiText.Resource(UiR.string.action_unpin),
icon = UiImage.Resource(UiR.drawable.pin_off_outline_24)
)
data object Delete : ConversationOption(
title = UiText.Resource(UiR.string.action_delete),
icon = UiImage.Resource(UiR.drawable.round_delete_outline_24)
)
}
@@ -1,6 +1,8 @@
package dev.meloda.fast.conversations.model package dev.meloda.fast.conversations.model
import androidx.compose.runtime.Immutable import androidx.compose.runtime.Immutable
import dev.meloda.fast.ui.model.api.ConversationsShowOptions
import dev.meloda.fast.ui.model.api.UiConversation
@Immutable @Immutable
data class ConversationsScreenState( data class ConversationsScreenState(
@@ -17,6 +17,7 @@ fun NavGraphBuilder.conversationsScreen(
onError: (BaseError) -> Unit, onError: (BaseError) -> Unit,
onConversationItemClicked: (id: Int) -> Unit, onConversationItemClicked: (id: Int) -> Unit,
onPhotoClicked: (url: String) -> Unit, onPhotoClicked: (url: String) -> Unit,
onCreateChatClicked: () -> Unit,
navController: NavController, navController: NavController,
) { ) {
composable<Conversations> { composable<Conversations> {
@@ -27,6 +28,7 @@ fun NavGraphBuilder.conversationsScreen(
onError = onError, onError = onError,
onConversationItemClicked = onConversationItemClicked, onConversationItemClicked = onConversationItemClicked,
onConversationPhotoClicked = onPhotoClicked, onConversationPhotoClicked = onPhotoClicked,
onCreateChatButtonClicked = onCreateChatClicked,
viewModel = viewModel viewModel = viewModel
) )
} }
@@ -48,11 +48,11 @@ import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp import androidx.compose.ui.unit.sp
import coil.compose.AsyncImage import coil.compose.AsyncImage
import dev.meloda.fast.conversations.model.ConversationOption
import dev.meloda.fast.conversations.model.UiConversation
import dev.meloda.fast.ui.basic.ContentAlpha import dev.meloda.fast.ui.basic.ContentAlpha
import dev.meloda.fast.ui.basic.LocalContentAlpha import dev.meloda.fast.ui.basic.LocalContentAlpha
import dev.meloda.fast.ui.components.DotsFlashing import dev.meloda.fast.ui.components.DotsFlashing
import dev.meloda.fast.ui.model.api.ConversationOption
import dev.meloda.fast.ui.model.api.UiConversation
import dev.meloda.fast.ui.util.getImage import dev.meloda.fast.ui.util.getImage
import dev.meloda.fast.ui.util.getResourcePainter import dev.meloda.fast.ui.util.getResourcePainter
import dev.meloda.fast.ui.util.getString import dev.meloda.fast.ui.util.getString
@@ -256,7 +256,7 @@ fun ConversationItem(
Row { Row {
if (conversation.interactionText != null) { if (conversation.interactionText != null) {
Text( Text(
text = conversation.interactionText, text = conversation.interactionText.orEmpty(),
color = MaterialTheme.colorScheme.primary color = MaterialTheme.colorScheme.primary
) )
Spacer(modifier = Modifier.width(4.dp)) Spacer(modifier = Modifier.width(4.dp))
@@ -22,10 +22,10 @@ import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import dev.meloda.fast.conversations.model.ConversationOption
import dev.meloda.fast.conversations.model.ConversationsScreenState import dev.meloda.fast.conversations.model.ConversationsScreenState
import dev.meloda.fast.conversations.model.UiConversation
import dev.meloda.fast.data.UserConfig import dev.meloda.fast.data.UserConfig
import dev.meloda.fast.ui.model.api.ConversationOption
import dev.meloda.fast.ui.model.api.UiConversation
import dev.meloda.fast.ui.theme.LocalBottomPadding import dev.meloda.fast.ui.theme.LocalBottomPadding
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
@@ -2,7 +2,6 @@ package dev.meloda.fast.conversations.presentation
import androidx.compose.animation.AnimatedVisibility import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.animateColorAsState import androidx.compose.animation.animateColorAsState
import androidx.compose.animation.core.Animatable
import androidx.compose.animation.core.animateFloatAsState import androidx.compose.animation.core.animateFloatAsState
import androidx.compose.animation.core.tween import androidx.compose.animation.core.tween
import androidx.compose.animation.fadeIn import androidx.compose.animation.fadeIn
@@ -48,12 +47,10 @@ import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableIntStateOf import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue import androidx.compose.runtime.setValue
import androidx.compose.runtime.snapshotFlow import androidx.compose.runtime.snapshotFlow
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.rotate
import androidx.compose.ui.platform.LocalView import androidx.compose.ui.platform.LocalView
import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.stringResource
@@ -62,29 +59,26 @@ import androidx.compose.ui.unit.DpOffset
import androidx.compose.ui.unit.IntOffset import androidx.compose.ui.unit.IntOffset
import androidx.compose.ui.unit.LayoutDirection import androidx.compose.ui.unit.LayoutDirection
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.core.view.HapticFeedbackConstantsCompat
import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.lifecycle.compose.collectAsStateWithLifecycle
import dev.chrisbanes.haze.hazeEffect 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.conversations.ConversationsViewModel import dev.meloda.fast.conversations.ConversationsViewModel
import dev.meloda.fast.conversations.model.ConversationOption
import dev.meloda.fast.conversations.model.ConversationsScreenState import dev.meloda.fast.conversations.model.ConversationsScreenState
import dev.meloda.fast.conversations.model.UiConversation
import dev.meloda.fast.datastore.AppSettings
import dev.meloda.fast.model.BaseError import dev.meloda.fast.model.BaseError
import dev.meloda.fast.ui.components.ErrorView import dev.meloda.fast.ui.components.ErrorView
import dev.meloda.fast.ui.components.FullScreenLoader import dev.meloda.fast.ui.components.FullScreenLoader
import dev.meloda.fast.ui.components.MaterialDialog import dev.meloda.fast.ui.components.MaterialDialog
import dev.meloda.fast.ui.components.NoItemsView import dev.meloda.fast.ui.components.NoItemsView
import dev.meloda.fast.ui.model.api.ConversationOption
import dev.meloda.fast.ui.model.api.UiConversation
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.LocalThemeConfig import dev.meloda.fast.ui.theme.LocalThemeConfig
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
import kotlinx.coroutines.launch
import dev.meloda.fast.ui.R as UiR import dev.meloda.fast.ui.R as UiR
@Composable @Composable
@@ -92,6 +86,7 @@ fun ConversationsRoute(
onError: (BaseError) -> Unit, onError: (BaseError) -> Unit,
onConversationItemClicked: (conversationId: Int) -> Unit, onConversationItemClicked: (conversationId: Int) -> Unit,
onConversationPhotoClicked: (url: String) -> Unit, onConversationPhotoClicked: (url: String) -> Unit,
onCreateChatButtonClicked: () -> Unit,
viewModel: ConversationsViewModel viewModel: ConversationsViewModel
) { ) {
val screenState by viewModel.screenState.collectAsStateWithLifecycle() val screenState by viewModel.screenState.collectAsStateWithLifecycle()
@@ -113,6 +108,7 @@ fun ConversationsRoute(
onRefreshDropdownItemClicked = viewModel::onRefresh, onRefreshDropdownItemClicked = viewModel::onRefresh,
onRefresh = viewModel::onRefresh, onRefresh = viewModel::onRefresh,
onConversationPhotoClicked = onConversationPhotoClicked, onConversationPhotoClicked = onConversationPhotoClicked,
onCreateChatButtonClicked = onCreateChatButtonClicked,
setScrollIndex = viewModel::setScrollIndex, setScrollIndex = viewModel::setScrollIndex,
setScrollOffset = viewModel::setScrollOffset setScrollOffset = viewModel::setScrollOffset
) )
@@ -132,7 +128,7 @@ fun ConversationsScreen(
screenState: ConversationsScreenState = ConversationsScreenState.EMPTY, screenState: ConversationsScreenState = ConversationsScreenState.EMPTY,
baseError: BaseError? = null, baseError: BaseError? = null,
canPaginate: Boolean = false, canPaginate: Boolean = false,
onSessionExpiredLogOutButtonClicked: () -> Unit, onSessionExpiredLogOutButtonClicked: () -> Unit = {},
onConversationItemClicked: (conversationId: Int) -> Unit = {}, onConversationItemClicked: (conversationId: Int) -> Unit = {},
onConversationItemLongClicked: (conversation: UiConversation) -> Unit = {}, onConversationItemLongClicked: (conversation: UiConversation) -> Unit = {},
onOptionClicked: (UiConversation, ConversationOption) -> Unit = { _, _ -> }, onOptionClicked: (UiConversation, ConversationOption) -> Unit = { _, _ -> },
@@ -140,6 +136,7 @@ fun ConversationsScreen(
onRefreshDropdownItemClicked: () -> Unit = {}, onRefreshDropdownItemClicked: () -> Unit = {},
onRefresh: () -> Unit = {}, onRefresh: () -> Unit = {},
onConversationPhotoClicked: (url: String) -> Unit = {}, onConversationPhotoClicked: (url: String) -> Unit = {},
onCreateChatButtonClicked: () -> Unit = {},
setScrollIndex: (Int) -> Unit = {}, setScrollIndex: (Int) -> Unit = {},
setScrollOffset: (Int) -> Unit = {} setScrollOffset: (Int) -> Unit = {}
) { ) {
@@ -284,37 +281,13 @@ fun ConversationsScreen(
} }
}, },
floatingActionButton = { floatingActionButton = {
val scope = rememberCoroutineScope()
val rotation = remember { Animatable(0f) }
Column { Column {
AnimatedVisibility( AnimatedVisibility(
visible = listState.isScrollingUp(), visible = listState.isScrollingUp(),
enter = slideIn { IntOffset(0, 600) } + fadeIn(tween(200)), enter = slideIn { IntOffset(0, 600) } + fadeIn(tween(200)),
exit = slideOut { IntOffset(0, 600) } + fadeOut(tween(200)) exit = slideOut { IntOffset(0, 600) } + fadeOut(tween(200))
) { ) {
FloatingActionButton( FloatingActionButton(onClick = onCreateChatButtonClicked) {
onClick = {
if (AppSettings.General.enableHaptic) {
view.performHapticFeedback(HapticFeedbackConstantsCompat.REJECT)
}
scope.launch {
for (i in 20 downTo 0 step 4) {
rotation.animateTo(
targetValue = i.toFloat(),
animationSpec = tween(50)
)
if (i > 0) {
rotation.animateTo(
targetValue = -i.toFloat(),
animationSpec = tween(50)
)
}
}
}
},
modifier = Modifier.rotate(rotation.value)
) {
Icon( Icon(
painter = painterResource(id = UiR.drawable.ic_baseline_create_24), painter = painterResource(id = UiR.drawable.ic_baseline_create_24),
contentDescription = "Add chat button" contentDescription = "Add chat button"
@@ -417,9 +390,7 @@ fun HandleDialogs(
) )
} }
if (showOptions.showPinDialog != null) { showOptions.showPinDialog?.let { conversation ->
val conversation = showOptions.showPinDialog
MaterialDialog( MaterialDialog(
onDismissRequest = viewModel::onPinDialogDismissed, onDismissRequest = viewModel::onPinDialogDismissed,
title = stringResource( title = stringResource(
@@ -14,8 +14,6 @@ import dev.meloda.fast.common.model.UiImage
import dev.meloda.fast.common.model.UiText import dev.meloda.fast.common.model.UiText
import dev.meloda.fast.common.model.parseString import dev.meloda.fast.common.model.parseString
import dev.meloda.fast.common.util.TimeUtils import dev.meloda.fast.common.util.TimeUtils
import dev.meloda.fast.conversations.model.ActionState
import dev.meloda.fast.conversations.model.UiConversation
import dev.meloda.fast.data.UserConfig import dev.meloda.fast.data.UserConfig
import dev.meloda.fast.data.VkMemoryCache import dev.meloda.fast.data.VkMemoryCache
import dev.meloda.fast.model.InteractionType import dev.meloda.fast.model.InteractionType
@@ -24,6 +22,8 @@ import dev.meloda.fast.model.api.data.AttachmentType
import dev.meloda.fast.model.api.domain.VkAttachment import dev.meloda.fast.model.api.domain.VkAttachment
import dev.meloda.fast.model.api.domain.VkConversation import dev.meloda.fast.model.api.domain.VkConversation
import dev.meloda.fast.model.api.domain.VkMessage import dev.meloda.fast.model.api.domain.VkMessage
import dev.meloda.fast.ui.model.api.ActionState
import dev.meloda.fast.ui.model.api.UiConversation
import dev.meloda.fast.ui.util.ImmutableList import dev.meloda.fast.ui.util.ImmutableList
import java.util.Calendar import java.util.Calendar
import java.util.Locale import java.util.Locale
+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.createchat"
}
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,243 @@
package dev.meloda.fast.conversations
import android.content.Context
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import coil.ImageLoader
import coil.request.ImageRequest
import dev.meloda.fast.common.extensions.listenValue
import dev.meloda.fast.common.extensions.setValue
import dev.meloda.fast.conversations.model.CreateChatScreenState
import dev.meloda.fast.data.State
import dev.meloda.fast.data.UserConfig
import dev.meloda.fast.data.processState
import dev.meloda.fast.datastore.UserSettings
import dev.meloda.fast.domain.FriendsUseCase
import dev.meloda.fast.domain.GetLocalUserByIdUseCase
import dev.meloda.fast.domain.MessagesUseCase
import dev.meloda.fast.domain.util.asPresentation
import dev.meloda.fast.model.BaseError
import dev.meloda.fast.network.VkErrorCode
import dev.meloda.fast.ui.model.api.UiFriend
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
interface CreateChatViewModel {
val screenState: StateFlow<CreateChatScreenState>
val baseError: StateFlow<BaseError?>
val currentOffset: StateFlow<Int>
val canPaginate: StateFlow<Boolean>
val isChatCreated: StateFlow<Int?>
fun onPaginationConditionsMet()
fun onRefresh()
fun onErrorConsumed()
fun toggleFriendSelection(userId: Int)
fun onTitleTextInputChanged(newTitle: String)
fun onCreateChatButtonClicked()
fun onNavigatedBack()
}
class CreateChatViewModelImpl(
private val friendsUseCase: FriendsUseCase,
private val messagesUseCase: MessagesUseCase,
private val imageLoader: ImageLoader,
private val applicationContext: Context,
private val getLocalUserByIdUseCase: GetLocalUserByIdUseCase,
private val userSettings: UserSettings
) : CreateChatViewModel, ViewModel() {
override val screenState = MutableStateFlow(CreateChatScreenState.EMPTY)
override val baseError = MutableStateFlow<BaseError?>(null)
override val currentOffset = MutableStateFlow(0)
override val canPaginate = MutableStateFlow(false)
override val isChatCreated = MutableStateFlow<Int?>(null)
private val useContactNames: Boolean = userSettings.useContactNames.value
init {
loadFriends()
}
override fun onPaginationConditionsMet() {
currentOffset.update { screenState.value.friends.size }
loadFriends()
}
override fun onRefresh() {
onErrorConsumed()
loadFriends(offset = 0)
}
override fun onErrorConsumed() {
baseError.setValue { null }
}
override fun toggleFriendSelection(userId: Int) {
val newSelectionList = screenState.value.selectedFriendsIds.toMutableList()
if (newSelectionList.contains(userId)) {
newSelectionList.remove(userId)
} else {
newSelectionList.add(userId)
}
screenState.setValue { old ->
old.copy(selectedFriendsIds = newSelectionList)
}
}
override fun onTitleTextInputChanged(newTitle: String) {
screenState.setValue { old -> old.copy(chatTitle = newTitle) }
}
override fun onCreateChatButtonClicked() {
createChat()
}
override fun onNavigatedBack() {
viewModelScope.launch(Dispatchers.Main) {
isChatCreated.emit(null)
}
}
private fun loadFriends(
offset: Int = currentOffset.value
) {
friendsUseCase.getFriends(count = LOAD_COUNT, offset = offset)
.listenValue(viewModelScope) { state ->
state.processState(
error = ::handleError,
success = { response ->
val itemsCountSufficient = response.size == LOAD_COUNT
canPaginate.setValue { itemsCountSufficient }
val paginationExhausted = !itemsCountSufficient &&
screenState.value.friends.isNotEmpty()
val imagesToPreload =
response.mapNotNull { it.photo100.takeIf { !it.isNullOrEmpty() } }
imagesToPreload.forEach { url ->
imageLoader.enqueue(
ImageRequest.Builder(applicationContext)
.data(url)
.build()
)
}
friendsUseCase.storeUsers(response)
val loadedFriends = response.map {
it.asPresentation(useContactNames)
}
val newState = screenState.value.copy(
isPaginationExhausted = paginationExhausted
)
if (offset == 0) {
screenState.setValue {
newState.copy(friends = loadedFriends)
}
} else {
screenState.setValue {
newState.copy(
friends = newState.friends.plus(loadedFriends)
)
}
}
}
)
screenState.setValue { old ->
old.copy(
isLoading = offset == 0 && state.isLoading(),
isPaginating = offset > 0 && state.isLoading()
)
}
}
}
private fun createChat() {
viewModelScope.launch {
val title = screenState.value.chatTitle.takeUnless(String::isBlank)
val accountAsFriend =
getLocalUserByIdUseCase.proceed(UserConfig.userId)?.asPresentation(useContactNames)
val accountList = accountAsFriend?.let(::listOf) ?: emptyList()
val selectedFriends = screenState.value.selectedFriendsIds
.takeIf { it.isNotEmpty() }
?.mapNotNull { userId -> screenState.value.friends.find { it.userId == userId } }
messagesUseCase.createChat(
userIds = selectedFriends?.map { it.userId },
title = title
?: (accountList + selectedFriends.orEmpty()).joinToString(transform = UiFriend::firstName)
).listenValue(viewModelScope) { state ->
state.processState(
error = ::handleError,
success = { response ->
withContext(Dispatchers.Main) {
isChatCreated.emit(2_000_000_000 + response)
}
}
)
}
}
}
private fun handleError(error: State.Error) {
when (error) {
is State.Error.ApiError -> {
when (error.errorCode) {
VkErrorCode.USER_AUTHORIZATION_FAILED -> {
baseError.setValue { BaseError.SessionExpired }
}
else -> {
baseError.setValue {
BaseError.SimpleError(message = error.errorMessage)
}
}
}
}
State.Error.ConnectionError -> {
baseError.setValue {
BaseError.SimpleError(message = "Connection error")
}
}
State.Error.InternalError -> {
baseError.setValue {
BaseError.SimpleError(message = "Internal error")
}
}
State.Error.UnknownError -> {
baseError.setValue {
BaseError.SimpleError(message = "Unknown error")
}
}
else -> Unit
}
}
companion object {
const val LOAD_COUNT = 30
}
}
@@ -0,0 +1,9 @@
package dev.meloda.fast.conversations.di
import dev.meloda.fast.conversations.CreateChatViewModelImpl
import org.koin.core.module.dsl.viewModelOf
import org.koin.dsl.module
val createChatModule = module {
viewModelOf(::CreateChatViewModelImpl)
}
@@ -0,0 +1,25 @@
package dev.meloda.fast.conversations.model
import androidx.compose.runtime.Immutable
import dev.meloda.fast.ui.model.api.UiFriend
@Immutable
data class CreateChatScreenState(
val isLoading: Boolean,
val isPaginating: Boolean,
val isPaginationExhausted: Boolean,
val friends: List<UiFriend>,
val selectedFriendsIds: List<Int>,
val chatTitle: String
) {
companion object {
val EMPTY: CreateChatScreenState = CreateChatScreenState(
isLoading = true,
isPaginating = false,
isPaginationExhausted = false,
friends = emptyList(),
selectedFriendsIds = emptyList(),
chatTitle = ""
)
}
}
@@ -0,0 +1,36 @@
package dev.meloda.fast.conversations.navigation
import androidx.navigation.NavController
import androidx.navigation.NavGraphBuilder
import androidx.navigation.compose.composable
import dev.meloda.fast.conversations.CreateChatViewModel
import dev.meloda.fast.conversations.CreateChatViewModelImpl
import dev.meloda.fast.conversations.presentation.CreateChatRoute
import dev.meloda.fast.ui.extensions.sharedViewModel
import kotlinx.serialization.Serializable
@Serializable
object CreateChat
fun NavGraphBuilder.createChatScreen(
onChatCreated: (Int) -> Unit,
navController: NavController,
) {
composable<CreateChat> {
val viewModel: CreateChatViewModel =
it.sharedViewModel<CreateChatViewModelImpl>(navController = navController)
CreateChatRoute(
onError = {
},
onBack = navController::popBackStack,
onChatCreated = onChatCreated,
viewModel = viewModel
)
}
}
fun NavController.navigateToCreateChat() {
this.navigate(CreateChat)
}
@@ -0,0 +1,110 @@
package dev.meloda.fast.conversations.presentation
import androidx.compose.foundation.Image
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.material3.Checkbox
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.ColorFilter
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import coil.compose.AsyncImage
import dev.meloda.fast.ui.R
import dev.meloda.fast.ui.model.api.UiFriend
@Composable
fun CreateChatItem(
modifier: Modifier = Modifier,
friend: UiFriend,
maxLines: Int,
isSelected: Boolean,
onItemClicked: (Int) -> Unit
) {
Row(
modifier = modifier
.fillMaxWidth()
.clickable { onItemClicked(friend.userId) }
.padding(vertical = 8.dp),
verticalAlignment = Alignment.CenterVertically
) {
Spacer(modifier = Modifier.width(16.dp))
val friendAvatar = friend.avatar?.extractUrl()
Box(modifier = Modifier.size(56.dp)) {
if (friendAvatar == null) {
Image(
modifier = Modifier
.fillMaxSize()
.clip(CircleShape),
painter = painterResource(id = R.drawable.ic_account_circle_cut),
contentDescription = "Avatar",
colorFilter = ColorFilter.tint(MaterialTheme.colorScheme.onBackground)
)
} else {
AsyncImage(
model = friendAvatar,
contentDescription = null,
modifier = Modifier
.fillMaxSize()
.clip(CircleShape),
placeholder = painterResource(id = R.drawable.ic_account_circle_cut)
)
}
if (friend.onlineStatus.isOnline()) {
Box(
modifier = Modifier
.clip(CircleShape)
.size(18.dp)
.background(MaterialTheme.colorScheme.background)
.padding(2.dp)
.align(Alignment.BottomEnd)
) {
Box(
modifier = Modifier
.clip(CircleShape)
.matchParentSize()
.background(MaterialTheme.colorScheme.primary)
)
}
}
}
Spacer(modifier = Modifier.width(16.dp))
Text(
text = friend.title,
minLines = 1,
maxLines = maxLines,
style = MaterialTheme.typography.headlineSmall.copy(fontSize = 20.sp),
modifier = Modifier.weight(1f)
)
Spacer(modifier = Modifier.width(16.dp))
Checkbox(
checked = isSelected,
onCheckedChange = { onItemClicked(friend.userId) },
)
Spacer(modifier = Modifier.width(16.dp))
}
}
@@ -0,0 +1,101 @@
package dev.meloda.fast.conversations.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.layout.navigationBarsPadding
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.LazyListState
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.rounded.KeyboardArrowUp
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.IconButtonDefaults
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.material3.TextField
import androidx.compose.runtime.Composable
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.unit.dp
import dev.meloda.fast.conversations.model.CreateChatScreenState
import dev.meloda.fast.ui.R
import dev.meloda.fast.ui.model.api.UiFriend
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
@Composable
fun CreateChatList(
screenState: CreateChatScreenState,
state: LazyListState,
maxLines: Int,
modifier: Modifier,
padding: PaddingValues,
onItemClicked: (Int) -> Unit,
onTitleTextInputChanged: (String) -> Unit
) {
val coroutineScope = rememberCoroutineScope()
LazyColumn(
modifier = modifier,
state = state
) {
item {
Spacer(modifier = Modifier.height(padding.calculateTopPadding()))
}
items(
items = screenState.friends,
key = UiFriend::userId,
) { friend ->
CreateChatItem(
maxLines = maxLines,
modifier = Modifier.animateItem(fadeInSpec = null, fadeOutSpec = null),
friend = friend,
isSelected = screenState.selectedFriendsIds.contains(friend.userId),
onItemClicked = onItemClicked
)
}
item {
Column(
modifier = Modifier
.fillMaxWidth()
.animateItem(fadeInSpec = null, fadeOutSpec = null),
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(
imageVector = Icons.Rounded.KeyboardArrowUp,
contentDescription = null
)
}
}
Spacer(modifier = Modifier.height(8.dp))
Spacer(modifier = Modifier.navigationBarsPadding())
}
}
}
}
@@ -0,0 +1,342 @@
package dev.meloda.fast.conversations.presentation
import androidx.compose.animation.animateColorAsState
import androidx.compose.animation.core.animateDpAsState
import androidx.compose.animation.core.animateFloatAsState
import androidx.compose.animation.core.tween
import androidx.compose.foundation.background
import androidx.compose.foundation.border
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.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.imePadding
import androidx.compose.foundation.layout.navigationBarsPadding
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.statusBars
import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.rounded.ArrowBack
import androidx.compose.material.icons.rounded.Done
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.ExtendedFloatingActionButton
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text
import androidx.compose.material3.TextField
import androidx.compose.material3.TextFieldDefaults
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.mutableIntStateOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.focus.onFocusChanged
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.LayoutDirection
import androidx.compose.ui.unit.dp
import androidx.lifecycle.compose.collectAsStateWithLifecycle
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.conversations.CreateChatViewModel
import dev.meloda.fast.conversations.model.CreateChatScreenState
import dev.meloda.fast.model.BaseError
import dev.meloda.fast.ui.components.ErrorView
import dev.meloda.fast.ui.components.FullScreenLoader
import dev.meloda.fast.ui.components.IconButton
import dev.meloda.fast.ui.components.NoItemsView
import dev.meloda.fast.ui.theme.LocalHazeState
import dev.meloda.fast.ui.theme.LocalThemeConfig
import dev.meloda.fast.ui.util.isScrollingUp
import dev.meloda.fast.ui.R as UiR
@Composable
fun CreateChatRoute(
onError: (BaseError) -> Unit,
onBack: () -> Unit,
onChatCreated: (Int) -> Unit,
viewModel: CreateChatViewModel
) {
val context = LocalContext.current
val screenState by viewModel.screenState.collectAsStateWithLifecycle()
val baseError by viewModel.baseError.collectAsStateWithLifecycle()
val canPaginate by viewModel.canPaginate.collectAsStateWithLifecycle()
val isChatCreated by viewModel.isChatCreated.collectAsStateWithLifecycle()
LaunchedEffect(isChatCreated) {
if (isChatCreated != null) {
onChatCreated(isChatCreated ?: -1)
viewModel.onNavigatedBack()
}
}
CreateChatScreen(
screenState = screenState,
baseError = baseError,
canPaginate = canPaginate,
onSessionExpiredLogOutButtonClicked = { onError(BaseError.SessionExpired) },
onPaginationConditionsMet = viewModel::onPaginationConditionsMet,
onBack = onBack,
onRefresh = viewModel::onRefresh,
onCreateChatButtonClicked = viewModel::onCreateChatButtonClicked,
onItemClicked = viewModel::toggleFriendSelection,
onTitleTextInputChanged = viewModel::onTitleTextInputChanged
)
}
@OptIn(
ExperimentalMaterial3Api::class,
ExperimentalHazeMaterialsApi::class,
)
@Composable
fun CreateChatScreen(
screenState: CreateChatScreenState = CreateChatScreenState.EMPTY,
baseError: BaseError? = null,
canPaginate: Boolean = false,
onSessionExpiredLogOutButtonClicked: () -> Unit = {},
onPaginationConditionsMet: () -> Unit = {},
onBack: () -> Unit = {},
onRefresh: () -> Unit = {},
onCreateChatButtonClicked: () -> Unit = {},
onItemClicked: (Int) -> Unit = {},
onTitleTextInputChanged: (String) -> Unit = {}
) {
val currentTheme = LocalThemeConfig.current
val maxLines by remember(currentTheme) {
mutableIntStateOf(if (currentTheme.enableMultiline) 2 else 1)
}
val listState = rememberLazyListState()
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
val toolbarColorAlpha by animateFloatAsState(
targetValue = if (!listState.canScrollBackward) 1f else 0f,
label = "toolbarColorAlpha",
animationSpec = tween(durationMillis = 50)
)
val toolbarContainerColor by animateColorAsState(
targetValue =
if (currentTheme.enableBlur || !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()
.background(
toolbarContainerColor.copy(
alpha = if (currentTheme.enableBlur) toolbarColorAlpha else 1f
)
)
.then(
if (currentTheme.enableBlur) {
Modifier.hazeEffect(
state = hazeState,
style = HazeMaterials.thick()
)
} else Modifier
)
) {
TopAppBar(
navigationIcon = {
IconButton(onClick = onBack) {
Icon(
imageVector = Icons.AutoMirrored.Rounded.ArrowBack,
contentDescription = null
)
}
},
title = {
Text(
text = stringResource(
id = if (screenState.isLoading) UiR.string.title_loading
else UiR.string.title_create_chat
),
maxLines = 1,
overflow = TextOverflow.Ellipsis,
style = MaterialTheme.typography.headlineSmall
)
},
colors = TopAppBarDefaults.topAppBarColors(
containerColor = toolbarContainerColor.copy(
alpha = 0f
)
),
modifier = Modifier.fillMaxWidth(),
)
var isTextFieldFocused by remember {
mutableStateOf(false)
}
val borderWidth by animateDpAsState(if (isTextFieldFocused) 1.5.dp else 0.dp)
val borderColor by animateColorAsState(
if (isTextFieldFocused) MaterialTheme.colorScheme.primary
else Color.Transparent
)
TextField(
modifier = Modifier
.height(58.dp)
.fillMaxWidth()
.padding(horizontal = 16.dp)
.border(
borderWidth,
borderColor,
RoundedCornerShape(16.dp)
)
.clip(RoundedCornerShape(16.dp))
.onFocusChanged { isTextFieldFocused = it.hasFocus },
value = screenState.chatTitle,
onValueChange = onTitleTextInputChanged,
label = { Text(text = stringResource(UiR.string.create_chat_title)) },
placeholder = { Text(text = stringResource(UiR.string.create_chat_title)) },
singleLine = true,
colors = TextFieldDefaults.colors(
unfocusedIndicatorColor = Color.Transparent,
focusedIndicatorColor = Color.Transparent,
)
)
Spacer(Modifier.height(16.dp))
}
},
floatingActionButton = {
if (baseError == null) {
Column(
modifier = Modifier
.imePadding()
.navigationBarsPadding()
) {
ExtendedFloatingActionButton(
onClick = onCreateChatButtonClicked,
expanded = listState.isScrollingUp(),
text = { Text(text = stringResource(UiR.string.action_create)) },
icon = {
Icon(
imageVector = Icons.Rounded.Done,
contentDescription = null
)
}
)
}
}
}
) { padding ->
when {
baseError != null -> {
when (baseError) {
is BaseError.SessionExpired -> {
ErrorView(
text = stringResource(UiR.string.session_expired),
buttonText = stringResource(UiR.string.action_log_out),
onButtonClick = onSessionExpiredLogOutButtonClicked
)
}
is BaseError.SimpleError -> {
ErrorView(
text = baseError.message,
buttonText = stringResource(UiR.string.try_again),
onButtonClick = onRefresh
)
}
}
}
screenState.isLoading && screenState.friends.isEmpty() -> FullScreenLoader()
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()),
)
}
) {
CreateChatList(
screenState = screenState,
state = listState,
maxLines = maxLines,
modifier = if (currentTheme.enableBlur) {
Modifier.hazeSource(state = hazeState)
} else {
Modifier
}.fillMaxSize(),
padding = padding,
onItemClicked = onItemClicked,
onTitleTextInputChanged = onTitleTextInputChanged
)
if (screenState.friends.isEmpty()) {
NoItemsView(
buttonText = stringResource(UiR.string.action_refresh),
onButtonClick = onRefresh
)
}
}
}
}
}
}
@@ -9,8 +9,8 @@ import dev.meloda.fast.data.processState
import dev.meloda.fast.datastore.UserSettings import dev.meloda.fast.datastore.UserSettings
import dev.meloda.fast.domain.FriendsUseCase import dev.meloda.fast.domain.FriendsUseCase
import dev.meloda.fast.domain.LoadUsersByIdsUseCase import dev.meloda.fast.domain.LoadUsersByIdsUseCase
import dev.meloda.fast.domain.util.asPresentation
import dev.meloda.fast.friends.model.FriendsScreenState import dev.meloda.fast.friends.model.FriendsScreenState
import dev.meloda.fast.friends.util.asPresentation
import dev.meloda.fast.model.BaseError import dev.meloda.fast.model.BaseError
import dev.meloda.fast.model.api.domain.VkUser import dev.meloda.fast.model.api.domain.VkUser
import dev.meloda.fast.network.VkErrorCode import dev.meloda.fast.network.VkErrorCode
@@ -1,6 +1,7 @@
package dev.meloda.fast.friends.model package dev.meloda.fast.friends.model
import androidx.compose.runtime.Immutable import androidx.compose.runtime.Immutable
import dev.meloda.fast.ui.model.api.UiFriend
@Immutable @Immutable
data class FriendsScreenState( data class FriendsScreenState(
@@ -27,8 +27,8 @@ import androidx.compose.ui.res.painterResource
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp import androidx.compose.ui.unit.sp
import coil.compose.AsyncImage import coil.compose.AsyncImage
import dev.meloda.fast.friends.model.UiFriend
import dev.meloda.fast.ui.R import dev.meloda.fast.ui.R
import dev.meloda.fast.ui.model.api.UiFriend
@Composable @Composable
fun FriendItem( fun FriendItem(
@@ -22,8 +22,7 @@ import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import dev.meloda.fast.friends.model.FriendsScreenState import dev.meloda.fast.friends.model.FriendsScreenState
import dev.meloda.fast.friends.model.UiFriend import dev.meloda.fast.ui.model.api.UiFriend
import dev.meloda.fast.ui.theme.LocalBottomPadding
import dev.meloda.fast.ui.util.ImmutableList import dev.meloda.fast.ui.util.ImmutableList
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
@@ -49,8 +48,6 @@ fun FriendsList(
val friends = uiFriends.toList() val friends = uiFriends.toList()
val bottomPadding = LocalBottomPadding.current
LazyColumn( LazyColumn(
modifier = modifier, modifier = modifier,
state = listState state = listState
@@ -29,6 +29,7 @@ import dev.meloda.fast.messageshistory.navigation.MessagesHistory
import dev.meloda.fast.messageshistory.util.asPresentation import dev.meloda.fast.messageshistory.util.asPresentation
import dev.meloda.fast.messageshistory.util.extractAvatar import dev.meloda.fast.messageshistory.util.extractAvatar
import dev.meloda.fast.messageshistory.util.extractTitle import dev.meloda.fast.messageshistory.util.extractTitle
import dev.meloda.fast.messageshistory.util.findMessageById
import dev.meloda.fast.model.BaseError import dev.meloda.fast.model.BaseError
import dev.meloda.fast.model.LongPollEvent import dev.meloda.fast.model.LongPollEvent
import dev.meloda.fast.model.api.domain.VkAttachment import dev.meloda.fast.model.api.domain.VkAttachment
@@ -160,7 +161,9 @@ class MessagesHistoryViewModelImpl(
val message = event.message val message = event.message
Log.d("MessagesHistoryViewModel", "handleNewMessage: $message") Log.d("MessagesHistoryViewModel", "handleNewMessage: $message")
if (message.peerId != screenState.value.conversationId) return if (message.peerId != screenState.value.conversationId) return
if (screenState.value.messages.findMessageById(message.id) != null) return
val randomIds = messages.value.map(VkMessage::randomId) val randomIds = messages.value.map(VkMessage::randomId)
if (message.randomId != 0 && message.randomId in randomIds) return if (message.randomId != 0 && message.randomId in randomIds) return
@@ -282,6 +282,7 @@ fun MessagesHistoryScreen(
// TODO: 11/07/2024, Danil Nikolaev: to VM // TODO: 11/07/2024, Danil Nikolaev: to VM
// TODO: 23-Mar-25, Danil Nikolaev: crash if not messages (ex. new chat)
onChatMaterialsDropdownItemClicked( onChatMaterialsDropdownItemClicked(
screenState.conversationId, screenState.conversationId,
screenState.messages.firstMessage().conversationMessageId screenState.messages.firstMessage().conversationMessageId
@@ -4,11 +4,13 @@ import dev.meloda.fast.messageshistory.model.UiItem
fun List<UiItem>.firstMessage(): UiItem.Message = filterIsInstance<UiItem.Message>().first() fun List<UiItem>.firstMessage(): UiItem.Message = filterIsInstance<UiItem.Message>().first()
fun List<UiItem>.firstMessageOrNull(): UiItem.Message? = filterIsInstance<UiItem.Message>().firstOrNull()
fun List<UiItem>.indexOfMessageById(messageId: Int): Int = fun List<UiItem>.indexOfMessageById(messageId: Int): Int =
indexOfFirst { it.id == messageId } indexOfFirst { it.id == messageId }
fun List<UiItem>.findMessageById(messageId: Int): UiItem.Message = fun List<UiItem>.findMessageById(messageId: Int): UiItem.Message? =
first { it.id == messageId } as UiItem.Message firstOrNull { it.id == messageId } as UiItem.Message?
fun List<UiItem>.indexOfMessageByCmId(cmId: Int): Int = fun List<UiItem>.indexOfMessageByCmId(cmId: Int): Int =
indexOfFirst { it.cmId == cmId } indexOfFirst { it.cmId == cmId }
+5 -1
View File
@@ -19,13 +19,16 @@ dependencyResolutionManagement {
rootProject.name = "fast-messenger" rootProject.name = "fast-messenger"
include(":app") include(":app")
include(":core:network") include(":core:network")
include(":core:data") include(":core:data")
include(":core:database") include(":core:database")
include(":core:datastore") include(":core:datastore")
include(":core:ui") include(":core:ui")
include(":core:common") include(":core:common")
include(":core:domain")
include(":core:model") include(":core:model")
include(":feature:messageshistory") include(":feature:messageshistory")
include(":feature:conversations") include(":feature:conversations")
include(":feature:auth") include(":feature:auth")
@@ -35,4 +38,5 @@ include(":feature:photoviewer")
include(":feature:settings") include(":feature:settings")
include(":feature:friends") include(":feature:friends")
include(":feature:profile") include(":feature:profile")
include(":core:domain") include(":feature:createchat")
include(":core:presentation")