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
@@ -11,10 +11,7 @@ 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.conversations.model.ConversationOption
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.extractAvatar
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.api.domain.VkConversation
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 kotlinx.coroutines.Job
import kotlinx.coroutines.flow.MutableStateFlow
@@ -84,10 +84,7 @@ class ConversationsViewModelImpl(
override val currentOffset = MutableStateFlow(0)
override val canPaginate = MutableStateFlow(false)
// TODO: 22-Dec-24, Danil Nikolaev: rewrite
private val useContactNames = {
userSettings.useContactNames.value
}
private val useContactNames: Boolean get() = userSettings.useContactNames.value
override fun onPaginationConditionsMet() {
currentOffset.update { screenState.value.conversations.size }
@@ -168,11 +165,11 @@ class ConversationsViewModelImpl(
conversations = old.conversations.map { item ->
item.copy(
isExpanded =
if (item.id == conversation.id) {
!item.isExpanded
} else {
false
},
if (item.id == conversation.id) {
!item.isExpanded
} else {
false
},
options = ImmutableList.copyOf(options)
)
}
@@ -191,7 +188,10 @@ class ConversationsViewModelImpl(
onPinDialogDismissed()
}
override fun onOptionClicked(conversation: UiConversation, option: ConversationOption) {
override fun onOptionClicked(
conversation: UiConversation,
option: ConversationOption
) {
when (option) {
ConversationOption.Delete -> {
emitShowOptions { old ->
@@ -322,21 +322,25 @@ class ConversationsViewModelImpl(
}
}
}
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
}
}
@@ -360,7 +364,7 @@ class ConversationsViewModelImpl(
conversations = newConversations.map {
it.asPresentation(
resources = resources,
useContactName = useContactNames()
useContactName = useContactNames
)
}
)
@@ -424,7 +428,7 @@ class ConversationsViewModelImpl(
conversations = newConversations.map {
it.asPresentation(
resources = resources,
useContactName = useContactNames()
useContactName = useContactNames
)
}
)
@@ -475,7 +479,7 @@ class ConversationsViewModelImpl(
conversations = newConversations.map {
it.asPresentation(
resources = resources,
useContactName = useContactNames()
useContactName = useContactNames
)
}
)
@@ -504,7 +508,7 @@ class ConversationsViewModelImpl(
conversations = newConversations.map {
it.asPresentation(
resources = resources,
useContactName = useContactNames()
useContactName = useContactNames
)
}
)
@@ -534,7 +538,7 @@ class ConversationsViewModelImpl(
conversations = newConversations.map {
it.asPresentation(
resources = resources,
useContactName = useContactNames()
useContactName = useContactNames
)
}
)
@@ -563,7 +567,7 @@ class ConversationsViewModelImpl(
conversations = newConversations.map {
it.asPresentation(
resources = resources,
useContactName = useContactNames()
useContactName = useContactNames
)
}
)
@@ -609,7 +613,7 @@ class ConversationsViewModelImpl(
old.copy(conversations = newConversations.map {
it.asPresentation(
resources = resources,
useContactName = useContactNames()
useContactName = useContactNames
)
})
}
@@ -650,7 +654,7 @@ class ConversationsViewModelImpl(
conversations = newConversations.map {
it.asPresentation(
resources = resources,
useContactName = useContactNames()
useContactName = useContactNames
)
}
)
@@ -701,7 +705,7 @@ class ConversationsViewModelImpl(
conversations = newConversations.map {
it.asPresentation(
resources = resources,
useContactName = useContactNames()
useContactName = useContactNames
)
}
)
@@ -735,7 +739,7 @@ class ConversationsViewModelImpl(
conversations = newConversations.map {
it.asPresentation(
resources = resources,
useContactName = useContactNames()
useContactName = useContactNames
)
}
)
@@ -1,20 +0,0 @@
package dev.meloda.fast.conversations.model
enum class ActionState {
PHANTOM, CALL_IN_PROGRESS, NONE;
// TODO: 11/04/2024, Danil Nikolaev: implement
fun getResourceId(): Int {
return -1
}
companion object {
fun parse(isPhantom: Boolean, isCallInProgress: Boolean): ActionState {
return when {
isPhantom -> PHANTOM
isCallInProgress -> CALL_IN_PROGRESS
else -> NONE
}
}
}
}
@@ -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
import androidx.compose.runtime.Immutable
import dev.meloda.fast.ui.model.api.ConversationsShowOptions
import dev.meloda.fast.ui.model.api.UiConversation
@Immutable
data class ConversationsScreenState(
@@ -1,14 +0,0 @@
package dev.meloda.fast.conversations.model
data class ConversationsShowOptions(
val showDeleteDialog: Int?,
val showPinDialog: UiConversation?
) {
companion object {
val EMPTY: ConversationsShowOptions = ConversationsShowOptions(
showDeleteDialog = null,
showPinDialog = null
)
}
}
@@ -1,31 +0,0 @@
package dev.meloda.fast.conversations.model
import androidx.compose.runtime.Immutable
import androidx.compose.ui.text.AnnotatedString
import dev.meloda.fast.common.model.UiImage
import dev.meloda.fast.model.api.PeerType
import dev.meloda.fast.model.api.domain.VkMessage
import dev.meloda.fast.ui.util.ImmutableList
@Immutable
data class UiConversation(
val id: Int,
val lastMessageId: Int?,
val avatar: UiImage?,
val title: String,
val unreadCount: String?,
val date: String,
val message: AnnotatedString,
val attachmentImage: UiImage?,
val isPinned: Boolean,
val actionImageId: Int,
val isBirthday: Boolean,
val isUnread: Boolean,
val isAccount: Boolean,
val isOnline: Boolean,
val lastMessage: VkMessage?,
val peerType: PeerType,
val interactionText: String?,
val isExpanded: Boolean,
val options: ImmutableList<ConversationOption>,
)
@@ -17,6 +17,7 @@ fun NavGraphBuilder.conversationsScreen(
onError: (BaseError) -> Unit,
onConversationItemClicked: (id: Int) -> Unit,
onPhotoClicked: (url: String) -> Unit,
onCreateChatClicked: () -> Unit,
navController: NavController,
) {
composable<Conversations> {
@@ -27,6 +28,7 @@ fun NavGraphBuilder.conversationsScreen(
onError = onError,
onConversationItemClicked = onConversationItemClicked,
onConversationPhotoClicked = onPhotoClicked,
onCreateChatButtonClicked = onCreateChatClicked,
viewModel = viewModel
)
}
@@ -48,11 +48,11 @@ 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.conversations.model.ConversationOption
import dev.meloda.fast.conversations.model.UiConversation
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.api.ConversationOption
import dev.meloda.fast.ui.model.api.UiConversation
import dev.meloda.fast.ui.util.getImage
import dev.meloda.fast.ui.util.getResourcePainter
import dev.meloda.fast.ui.util.getString
@@ -256,7 +256,7 @@ fun ConversationItem(
Row {
if (conversation.interactionText != null) {
Text(
text = conversation.interactionText,
text = conversation.interactionText.orEmpty(),
color = MaterialTheme.colorScheme.primary
)
Spacer(modifier = Modifier.width(4.dp))
@@ -22,10 +22,10 @@ import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
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.UiConversation
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 kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
@@ -2,7 +2,6 @@ package dev.meloda.fast.conversations.presentation
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.animateColorAsState
import androidx.compose.animation.core.Animatable
import androidx.compose.animation.core.animateFloatAsState
import androidx.compose.animation.core.tween
import androidx.compose.animation.fadeIn
@@ -48,12 +47,10 @@ import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
import androidx.compose.runtime.snapshotFlow
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.rotate
import androidx.compose.ui.platform.LocalView
import androidx.compose.ui.res.painterResource
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.LayoutDirection
import androidx.compose.ui.unit.dp
import androidx.core.view.HapticFeedbackConstantsCompat
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.ConversationsViewModel
import dev.meloda.fast.conversations.model.ConversationOption
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.ui.components.ErrorView
import dev.meloda.fast.ui.components.FullScreenLoader
import dev.meloda.fast.ui.components.MaterialDialog
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.LocalHazeState
import dev.meloda.fast.ui.theme.LocalThemeConfig
import dev.meloda.fast.ui.util.isScrollingUp
import kotlinx.coroutines.flow.collectLatest
import kotlinx.coroutines.flow.debounce
import kotlinx.coroutines.launch
import dev.meloda.fast.ui.R as UiR
@Composable
@@ -92,6 +86,7 @@ fun ConversationsRoute(
onError: (BaseError) -> Unit,
onConversationItemClicked: (conversationId: Int) -> Unit,
onConversationPhotoClicked: (url: String) -> Unit,
onCreateChatButtonClicked: () -> Unit,
viewModel: ConversationsViewModel
) {
val screenState by viewModel.screenState.collectAsStateWithLifecycle()
@@ -113,6 +108,7 @@ fun ConversationsRoute(
onRefreshDropdownItemClicked = viewModel::onRefresh,
onRefresh = viewModel::onRefresh,
onConversationPhotoClicked = onConversationPhotoClicked,
onCreateChatButtonClicked = onCreateChatButtonClicked,
setScrollIndex = viewModel::setScrollIndex,
setScrollOffset = viewModel::setScrollOffset
)
@@ -132,7 +128,7 @@ fun ConversationsScreen(
screenState: ConversationsScreenState = ConversationsScreenState.EMPTY,
baseError: BaseError? = null,
canPaginate: Boolean = false,
onSessionExpiredLogOutButtonClicked: () -> Unit,
onSessionExpiredLogOutButtonClicked: () -> Unit = {},
onConversationItemClicked: (conversationId: Int) -> Unit = {},
onConversationItemLongClicked: (conversation: UiConversation) -> Unit = {},
onOptionClicked: (UiConversation, ConversationOption) -> Unit = { _, _ -> },
@@ -140,6 +136,7 @@ fun ConversationsScreen(
onRefreshDropdownItemClicked: () -> Unit = {},
onRefresh: () -> Unit = {},
onConversationPhotoClicked: (url: String) -> Unit = {},
onCreateChatButtonClicked: () -> Unit = {},
setScrollIndex: (Int) -> Unit = {},
setScrollOffset: (Int) -> Unit = {}
) {
@@ -284,37 +281,13 @@ fun ConversationsScreen(
}
},
floatingActionButton = {
val scope = rememberCoroutineScope()
val rotation = remember { Animatable(0f) }
Column {
AnimatedVisibility(
visible = listState.isScrollingUp(),
enter = slideIn { IntOffset(0, 600) } + fadeIn(tween(200)),
exit = slideOut { IntOffset(0, 600) } + fadeOut(tween(200))
) {
FloatingActionButton(
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)
) {
FloatingActionButton(onClick = onCreateChatButtonClicked) {
Icon(
painter = painterResource(id = UiR.drawable.ic_baseline_create_24),
contentDescription = "Add chat button"
@@ -417,9 +390,7 @@ fun HandleDialogs(
)
}
if (showOptions.showPinDialog != null) {
val conversation = showOptions.showPinDialog
showOptions.showPinDialog?.let { conversation ->
MaterialDialog(
onDismissRequest = viewModel::onPinDialogDismissed,
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.parseString
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.VkMemoryCache
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.VkConversation
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 java.util.Calendar
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.domain.FriendsUseCase
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.util.asPresentation
import dev.meloda.fast.model.BaseError
import dev.meloda.fast.model.api.domain.VkUser
import dev.meloda.fast.network.VkErrorCode
@@ -1,6 +1,7 @@
package dev.meloda.fast.friends.model
import androidx.compose.runtime.Immutable
import dev.meloda.fast.ui.model.api.UiFriend
@Immutable
data class FriendsScreenState(
@@ -1,12 +0,0 @@
package dev.meloda.fast.friends.model
import dev.meloda.fast.common.model.UiImage
import dev.meloda.fast.model.api.domain.OnlineStatus
data class UiFriend(
val userId: Int,
val avatar: UiImage?,
val title: String,
val onlineStatus: OnlineStatus,
val photo400Orig: UiImage?
)
@@ -27,8 +27,8 @@ 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.friends.model.UiFriend
import dev.meloda.fast.ui.R
import dev.meloda.fast.ui.model.api.UiFriend
@Composable
fun FriendItem(
@@ -22,8 +22,7 @@ import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
import dev.meloda.fast.friends.model.FriendsScreenState
import dev.meloda.fast.friends.model.UiFriend
import dev.meloda.fast.ui.theme.LocalBottomPadding
import dev.meloda.fast.ui.model.api.UiFriend
import dev.meloda.fast.ui.util.ImmutableList
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
@@ -49,8 +48,6 @@ fun FriendsList(
val friends = uiFriends.toList()
val bottomPadding = LocalBottomPadding.current
LazyColumn(
modifier = modifier,
state = listState
@@ -1,20 +0,0 @@
package dev.meloda.fast.friends.util
import dev.meloda.fast.common.model.UiImage
import dev.meloda.fast.data.VkMemoryCache
import dev.meloda.fast.friends.model.UiFriend
import dev.meloda.fast.model.api.domain.VkUser
fun VkUser.asPresentation(
useContactNames: Boolean = false
): UiFriend = UiFriend(
userId = id,
avatar = photo100?.let(UiImage::Url),
title = if (useContactNames) {
VkMemoryCache.getContact(id)?.name ?: fullName
} else {
fullName
},
onlineStatus = onlineStatus,
photo400Orig = photo400Orig?.let(UiImage::Url)
)
@@ -29,6 +29,7 @@ import dev.meloda.fast.messageshistory.navigation.MessagesHistory
import dev.meloda.fast.messageshistory.util.asPresentation
import dev.meloda.fast.messageshistory.util.extractAvatar
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.LongPollEvent
import dev.meloda.fast.model.api.domain.VkAttachment
@@ -160,7 +161,9 @@ class MessagesHistoryViewModelImpl(
val message = event.message
Log.d("MessagesHistoryViewModel", "handleNewMessage: $message")
if (message.peerId != screenState.value.conversationId) return
if (screenState.value.messages.findMessageById(message.id) != null) return
val randomIds = messages.value.map(VkMessage::randomId)
if (message.randomId != 0 && message.randomId in randomIds) return
@@ -282,6 +282,7 @@ fun MessagesHistoryScreen(
// TODO: 11/07/2024, Danil Nikolaev: to VM
// TODO: 23-Mar-25, Danil Nikolaev: crash if not messages (ex. new chat)
onChatMaterialsDropdownItemClicked(
screenState.conversationId,
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>.firstMessageOrNull(): UiItem.Message? = filterIsInstance<UiItem.Message>().firstOrNull()
fun List<UiItem>.indexOfMessageById(messageId: Int): Int =
indexOfFirst { it.id == messageId }
fun List<UiItem>.findMessageById(messageId: Int): UiItem.Message =
first { it.id == messageId } as UiItem.Message
fun List<UiItem>.findMessageById(messageId: Int): UiItem.Message? =
firstOrNull { it.id == messageId } as UiItem.Message?
fun List<UiItem>.indexOfMessageByCmId(cmId: Int): Int =
indexOfFirst { it.cmId == cmId }