feat(auth): add web captcha handling

- replace manual captcha screen with WebView-based VK captcha flow
- handle captcha error 14 by showing the captcha overlay and retrying with success_token
- pass captcha redirect/result state through AppSettings
- remove old captcha ViewModel, navigation, validation, and DI
- add ACRA crash reporting
- add WIP message edit mode UI/state
- update Gradle wrapper, SDK config, and dependencies
This commit is contained in:
2026-05-03 05:49:16 +03:00
parent 97c59a85b6
commit df2c61d8d7
51 changed files with 776 additions and 689 deletions
@@ -19,7 +19,7 @@ interface MessagesHistoryViewModel {
val dialog: StateFlow<MessageDialog?>
val selectedMessages: StateFlow<List<VkMessage>>
val inputFieldFocusRequester: StateFlow<Boolean>
val showKeyboard: StateFlow<Boolean>
val isNeedToScrollToIndex: StateFlow<Int?>
@@ -54,6 +54,7 @@ interface MessagesHistoryViewModel {
fun onPinnedMessageClicked(messageId: Long)
fun onUnpinMessageClicked()
fun onEditSelectedMessageClicked()
fun onDeleteSelectedMessagesClicked()
fun onBoldClicked()
@@ -66,5 +67,7 @@ interface MessagesHistoryViewModel {
fun onRequestReplyToMessage(cmId: Long)
fun onKeyboardShown()
suspend fun loadMessageReadPeers(peerId: Long, cmId: Long): Int
}
@@ -45,6 +45,7 @@ import dev.meloda.fast.domain.MessagesUseCase
import dev.meloda.fast.domain.util.asPresentation
import dev.meloda.fast.domain.util.extractAvatar
import dev.meloda.fast.domain.util.extractReplySummary
import dev.meloda.fast.domain.util.extractReplyTitle
import dev.meloda.fast.domain.util.extractTitle
import dev.meloda.fast.messageshistory.model.ActionMode
import dev.meloda.fast.messageshistory.model.MessageDialog
@@ -55,7 +56,6 @@ import dev.meloda.fast.messageshistory.navigation.MessagesHistory
import dev.meloda.fast.model.BaseError
import dev.meloda.fast.model.LongPollParsedEvent
import dev.meloda.fast.model.api.domain.FormatDataType
import dev.meloda.fast.model.api.domain.VkAttachment
import dev.meloda.fast.model.api.domain.VkMessage
import dev.meloda.fast.model.api.domain.VkPhotoDomain
import dev.meloda.fast.network.VkErrorCode
@@ -65,6 +65,7 @@ import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
import kotlinx.coroutines.suspendCancellableCoroutine
import kotlinx.coroutines.withContext
import kotlinx.serialization.json.add
import kotlinx.serialization.json.buildJsonArray
@@ -73,7 +74,6 @@ import kotlinx.serialization.json.put
import java.io.File
import java.io.FileOutputStream
import kotlin.coroutines.resume
import kotlin.coroutines.suspendCoroutine
import kotlin.math.abs
import kotlin.random.Random
@@ -94,7 +94,7 @@ class MessagesHistoryViewModelImpl(
override val dialog = MutableStateFlow<MessageDialog?>(null)
override val selectedMessages = MutableStateFlow<List<VkMessage>>(emptyList())
override val inputFieldFocusRequester = MutableStateFlow(false)
override val showKeyboard = MutableStateFlow(false)
override val isNeedToScrollToIndex = MutableStateFlow<Int?>(null)
@@ -115,6 +115,8 @@ class MessagesHistoryViewModelImpl(
private var replyToCmId: Long? = null
private var editMessage: VkMessage? = null
init {
val arguments = MessagesHistory.from(savedStateHandle).arguments
@@ -229,7 +231,7 @@ class MessagesHistoryViewModelImpl(
override fun onDialogItemPicked(dialog: MessageDialog, bundle: Bundle) {
when (dialog) {
is MessageDialog.MessageOptions -> {
val messageId = bundle.getLong("messageId")
// val messageId = bundle.getLong("messageId")
val cmId = bundle.getLong("cmId")
when (val option = bundle.getParcelableCompat("option", MessageOption::class)) {
@@ -289,7 +291,10 @@ class MessagesHistoryViewModelImpl(
}
}
MessageOption.Edit -> {}
MessageOption.Edit -> {
editMessage(cmId)
syncUiMessages()
}
MessageOption.Delete -> {
this.dialog.setValue {
@@ -313,7 +318,14 @@ class MessagesHistoryViewModelImpl(
}
override fun onCloseButtonClicked() {
selectedMessages.setValue { emptyList() }
if (selectedMessages.value.isNotEmpty()) {
selectedMessages.setValue { emptyList() }
}
if (screenState.value.editCmId != null) {
stopEditMessage()
}
syncUiMessages()
}
@@ -329,8 +341,20 @@ class MessagesHistoryViewModelImpl(
screenState.setValue { old ->
old.copy(
message = newText,
actionMode = if (newText.text.isBlank()) ActionMode.RECORD_AUDIO
else ActionMode.SEND
actionMode =
when {
screenState.value.editCmId != null -> {
// TODO: 13/03/2026, Danil Nikolaev: also check if attachments is empty
if (newText.text.trim().isEmpty()) {
ActionMode.DELETE
} else {
ActionMode.EDIT
}
}
newText.text.trim().isEmpty() -> ActionMode.RECORD_AUDIO
else -> ActionMode.SEND
}
)
}
updateStyles()
@@ -347,13 +371,9 @@ class MessagesHistoryViewModelImpl(
override fun onActionButtonClicked() {
when (screenState.value.actionMode) {
ActionMode.DELETE -> {
ActionMode.DELETE -> confirmDeleteCurrentEditMessage()
}
ActionMode.EDIT -> {
}
ActionMode.EDIT -> editCurrentEditMessage()
ActionMode.RECORD_AUDIO -> {
screenState.setValue { it.copy(actionMode = ActionMode.RECORD_VIDEO) }
@@ -429,6 +449,16 @@ class MessagesHistoryViewModelImpl(
}
}
override fun onEditSelectedMessageClicked() {
val cmId = selectedMessages.value.firstOrNull()?.cmId ?: return
selectedMessages.setValue { emptyList() }
editMessage(cmId)
syncUiMessages()
}
override fun onDeleteSelectedMessagesClicked() {
dialog.setValue {
MessageDialog.MessagesDelete(selectedMessages.value)
@@ -438,7 +468,7 @@ class MessagesHistoryViewModelImpl(
private fun replyToMessage(cmId: Long) {
val messageToReply = messages.value.find { it.cmId == cmId } ?: return
inputFieldFocusRequester.setValue { true }
showKeyboard.setValue { true }
replyToCmId = cmId
screenState.setValue { old ->
old.copy(
@@ -448,6 +478,56 @@ class MessagesHistoryViewModelImpl(
}
}
private fun editMessage(cmId: Long) {
this.screenState.setValue { old ->
old.copy(editCmId = cmId)
}
val messageToEdit = messages.value.firstOrNull { it.cmId == cmId } ?: return
editMessage = messageToEdit
lastMessageText = screenState.value.message.text
var newState = screenState.value.copy(
message = TextFieldValue(
text = messageToEdit.text.orEmpty(),
selection = TextRange(messageToEdit.text.orEmpty().length)
),
actionMode = ActionMode.EDIT
)
messageToEdit.replyMessage?.let { reply ->
replyToCmId = reply.cmId
newState = newState.copy(
replyTitle = reply.extractReplyTitle(),
replyText = reply.extractReplySummary(resourceProvider.resources)
)
}
showKeyboard.setValue { true }
screenState.setValue { newState }
}
private fun stopEditMessage() {
val lastText = lastMessageText.orEmpty().trim()
screenState.setValue { old ->
old.copy(
editCmId = null,
message = TextFieldValue(
text = lastText,
selection = TextRange(lastText.length)
),
actionMode = if (lastText.isBlank()) ActionMode.RECORD_AUDIO
else ActionMode.SEND,
// TODO: 13/03/2026, Danil Nikolaev: use last reply
replyTitle = null,
replyText = null
)
}
}
private var formatData = VkMessage.FormatData("1", emptyList())
private fun updateStyles() {
@@ -580,23 +660,28 @@ class MessagesHistoryViewModelImpl(
replyToMessage(cmId)
}
override suspend fun loadMessageReadPeers(peerId: Long, cmId: Long): Int = suspendCoroutine {
viewModelScope.launch {
getMessageReadPeersUseCase
.invoke(peerId = peerId, cmId = cmId)
.listenValue(viewModelScope) { state ->
state.processState(
error = { error ->
it.resume(-1)
},
success = { count ->
it.resume(count)
}
)
}
}
override fun onKeyboardShown() {
showKeyboard.setValue { false }
}
override suspend fun loadMessageReadPeers(peerId: Long, cmId: Long): Int =
suspendCancellableCoroutine {
viewModelScope.launch {
getMessageReadPeersUseCase
.invoke(peerId = peerId, cmId = cmId)
.listenValue(viewModelScope) { state ->
state.processState(
error = { error ->
it.resume(-1)
},
success = { count ->
it.resume(count)
}
)
}
}
}
private fun handleNewMessage(event: LongPollParsedEvent.NewMessage) {
val message = event.message
@@ -988,11 +1073,13 @@ class MessagesHistoryViewModelImpl(
message = newMessage.text,
forward = forward,
attachments = null,
formatData = newMessage.formatData
formatData = newMessage.formatData,
).listenValue(viewModelScope) { state ->
state.processState(
any = { sendingMessages.remove(newMessage) },
error = { error ->
Log.d("MessagesHistoryViewModelImpl", "sendMessage: ERROR: $error")
val failedId = -500_000L - failedMessages.size
val newFailedMessage = newMessage.copy(id = failedId)
failedMessages += newFailedMessage
@@ -1015,6 +1102,51 @@ class MessagesHistoryViewModelImpl(
}
}
private fun confirmDeleteCurrentEditMessage() {
val currentMessage = editMessage ?: return
this.dialog.setValue {
MessageDialog.MessageDelete(currentMessage)
}
}
private fun editCurrentEditMessage() {
replyToCmId = null
val newText = screenState.value.message.text
val lastText = lastMessageText.orEmpty().trim()
screenState.setValue { old ->
old.copy(
editCmId = null,
message = TextFieldValue(
text = lastText,
selection = TextRange(lastText.length)
),
actionMode = if (lastText.isBlank()) ActionMode.RECORD_AUDIO
else ActionMode.SEND,
// TODO: 13/03/2026, Danil Nikolaev: save last reply
replyTitle = null,
replyText = null
)
}
syncUiMessages()
// TODO: 13/03/2026, Danil Nikolaev: actually edit message
val newMessage = editMessage?.copy(
replyMessage = if (replyToCmId == null) null else editMessage?.replyMessage,
text = newText
) ?: return
// TODO: 13/03/2026, Danil Nikolaev: check if message is exact same, then do not edit
Log.d("MessagesHistoryViewModelImpl", "editMessage: $newMessage")
}
private fun markAsImportant(
messageIds: List<Long>,
important: Boolean,
@@ -1118,29 +1250,6 @@ class MessagesHistoryViewModelImpl(
}
}
fun editMessage(
originalMessage: VkMessage,
peerid: Long,
messageid: Long,
newText: String? = null,
attachments: List<VkAttachment>? = null,
) {
viewModelScope.launch(Dispatchers.IO) {
// sendRequest {
// messagesRepository.edit(
// MessagesEditRequest(
// peerId = peerId,
// messageId = messageId,
// message = newText,
// attachments = attachments
// )
// )
// } ?: return@launch
// TODO: 25.08.2023, Danil Nikolaev: update message
}
}
private fun readMessage(message: VkMessage) {
messagesUseCase.markAsRead(
peerId = screenState.value.convoId,
@@ -1237,7 +1346,8 @@ class MessagesHistoryViewModelImpl(
nextMessage = messages.getOrNull(index - 1),
showTimeInActionMessages = AppSettings.Experimental.showTimeInActionMessages,
convo = screenState.value.convo,
isSelected = selectedMessages.indexOfFirstOrNull { it.id == message.id } != null
isSelected = screenState.value.editCmId == message.cmId ||
selectedMessages.indexOfFirstOrNull { it.id == message.id } != null
)
}
uiMessages.setValue { newUiMessages }
@@ -26,7 +26,8 @@ data class MessagesHistoryScreenState(
val pinnedTitle: String?,
val pinnedSummary: AnnotatedString?,
val replyTitle: String?,
val replyText: AnnotatedString?
val replyText: AnnotatedString?,
val editCmId: Long?,
) {
companion object {
@@ -48,6 +49,7 @@ data class MessagesHistoryScreenState(
pinnedSummary = null,
replyTitle = null,
replyText = null,
editCmId = null,
)
}
}
@@ -81,7 +81,7 @@ fun InputBar(
actionMode: ActionMode,
replyTitle: String?,
replyText: AnnotatedString?,
inputFieldFocusRequester: Boolean,
showKeyboard: Boolean,
onMessageInputChanged: (TextFieldValue) -> Unit = {},
onBoldRequested: () -> Unit = {},
onItalicRequested: () -> Unit = {},
@@ -92,7 +92,8 @@ fun InputBar(
onEmojiButtonLongClicked: () -> Unit = {},
onAttachmentButtonClicked: () -> Unit = {},
onActionButtonClicked: () -> Unit = {},
onReplyCloseClicked: () -> Unit = {}
onReplyCloseClicked: () -> Unit = {},
onKeyboardShown: () -> Unit
) {
val view = LocalView.current
val context = LocalContext.current
@@ -106,8 +107,9 @@ fun InputBar(
val focusRequester = remember { FocusRequester() }
LaunchedEffect(inputFieldFocusRequester) {
if (inputFieldFocusRequester) {
LaunchedEffect(showKeyboard) {
if (showKeyboard) {
onKeyboardShown()
focusRequester.requestFocus()
}
}
@@ -360,6 +362,7 @@ private fun InputBarPreview() {
actionMode = ActionMode.SEND,
replyTitle = "Иннокентий Панфилович",
replyText = "Ого, ром!".annotated(),
inputFieldFocusRequester = false
showKeyboard = false,
onKeyboardShown = {}
)
}
@@ -29,7 +29,7 @@ fun MessagesHistoryRoute(
val baseError by viewModel.baseError.collectAsStateWithLifecycle()
val canPaginate by viewModel.canPaginate.collectAsStateWithLifecycle()
val scrollIndex by viewModel.isNeedToScrollToIndex.collectAsStateWithLifecycle()
val inputFieldFocusRequester by viewModel.inputFieldFocusRequester.collectAsStateWithLifecycle()
val showKeyboard by viewModel.showKeyboard.collectAsStateWithLifecycle()
LaunchedEffect(navigationEvent) {
val needToConsume = when (val navigation = navigationEvent) {
@@ -55,7 +55,7 @@ fun MessagesHistoryRoute(
canPaginate = canPaginate,
showEmojiButton = AppSettings.General.showEmojiButton,
showAttachmentButton = AppSettings.General.showAttachmentButton,
inputFieldFocusRequester = inputFieldFocusRequester,
showKeyboard = showKeyboard,
onBack = onBack,
onClose = viewModel::onCloseButtonClicked,
onScrolledToIndex = viewModel::onScrolledToIndex,
@@ -72,6 +72,7 @@ fun MessagesHistoryRoute(
onPhotoClicked = onNavigateToPhotoViewer,
onPinnedMessageClicked = viewModel::onPinnedMessageClicked,
onUnpinMessageButtonClicked = viewModel::onUnpinMessageClicked,
onEditSelectedMessageClicked = viewModel::onEditSelectedMessageClicked,
onDeleteSelectedButtonClicked = viewModel::onDeleteSelectedMessagesClicked,
onBoldRequested = viewModel::onBoldClicked,
onItalicRequested = viewModel::onItalicClicked,
@@ -80,6 +81,7 @@ fun MessagesHistoryRoute(
onRegularRequested = viewModel::onRegularClicked,
onReplyCloseClicked = viewModel::onReplyCloseClicked,
onRequestReplyToMessage = viewModel::onRequestReplyToMessage,
onKeyboardShown = viewModel::onKeyboardShown
)
HandleDialogs(
@@ -31,12 +31,14 @@ import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalView
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.input.TextFieldValue
import androidx.compose.ui.unit.LayoutDirection
import androidx.compose.ui.unit.dp
import androidx.core.view.HapticFeedbackConstantsCompat
import dev.chrisbanes.haze.HazeState
import dev.chrisbanes.haze.materials.ExperimentalHazeMaterialsApi
import dev.meloda.fast.common.model.UiImage
import dev.meloda.fast.data.UserConfig
import dev.meloda.fast.datastore.AppSettings
import dev.meloda.fast.domain.util.indexOfMessageByCmId
@@ -69,13 +71,14 @@ fun MessagesHistoryScreen(
canPaginate: Boolean = false,
showEmojiButton: Boolean = false,
showAttachmentButton: Boolean = false,
inputFieldFocusRequester: Boolean,
showKeyboard: Boolean,
onBack: () -> Unit = {},
onClose: () -> Unit = {},
onScrolledToIndex: () -> Unit = {},
onSessionExpiredLogOutButtonClicked: () -> Unit = {},
onTopBarClicked: () -> Unit = {},
onRefresh: () -> Unit = {},
onEditSelectedMessageClicked: () -> Unit = {},
onPaginationConditionsMet: () -> Unit = {},
onMessageInputChanged: (TextFieldValue) -> Unit = {},
onAttachmentButtonClicked: () -> Unit = {},
@@ -93,7 +96,8 @@ fun MessagesHistoryScreen(
onUnderlineRequested: () -> Unit = {},
onRegularRequested: () -> Unit = {},
onReplyCloseClicked: () -> Unit = {},
onRequestReplyToMessage: (cmId: Long) -> Unit = {}
onRequestReplyToMessage: (cmId: Long) -> Unit = {},
onKeyboardShown: () -> Unit
) {
val context = LocalContext.current
val view = LocalView.current
@@ -114,7 +118,7 @@ fun MessagesHistoryScreen(
}
BackHandler(
enabled = selectedMessages.isNotEmpty(),
enabled = selectedMessages.isNotEmpty() || screenState.editCmId != null,
onBack = onClose
)
@@ -162,6 +166,9 @@ fun MessagesHistoryScreen(
derivedStateOf { selectedMessages.size == 1 }
}
val isLoadingText = stringResource(R.string.title_loading)
val editMessageText = stringResource(R.string.title_edit_message)
Scaffold(
modifier = Modifier.fillMaxSize(),
contentWindowInsets = WindowInsets.statusBars,
@@ -169,7 +176,8 @@ fun MessagesHistoryScreen(
val topBarTitle by remember(screenState, selectedMessages) {
derivedStateOf {
when {
screenState.isLoading -> context.getString(R.string.title_loading)
screenState.isLoading -> isLoadingText
screenState.editCmId != null -> editMessageText
selectedMessages.isNotEmpty() -> "(${selectedMessages.size})"
else -> screenState.title
}
@@ -179,13 +187,16 @@ fun MessagesHistoryScreen(
MessagesHistoryTopBarContainer(
hazeState = hazeState,
showReplyAction = showReplyAction,
showEditAction = selectedMessages.size == 1,
topBarContainerColor = topBarContainerColor,
topBarContainerColorAlpha = topBarContainerColorAlpha,
isClickable = !(screenState.isLoading && messages.isEmpty()),
isMessagesSelecting = selectedMessages.isNotEmpty(),
isPeerAccount = screenState.convoId == UserConfig.userId,
avatar = screenState.avatar,
avatarUrl = screenState.avatar.takeIf { it is UiImage.Url }?.extractUrl(),
avatarResourceId = screenState.avatar.takeIf { it is UiImage.Resource }?.extractResId(),
title = topBarTitle,
isEditing = screenState.editCmId != null,
showHorizontalProgressBar = screenState.isLoading && messages.isNotEmpty(),
showPinnedContainer = !screenState.isLoading && pinnedMessage != null,
pinnedMessage = pinnedMessage,
@@ -196,6 +207,7 @@ fun MessagesHistoryScreen(
onBack = onBack,
onClose = onClose,
onDeleteSelectedButtonClicked = onDeleteSelectedButtonClicked,
onEditSelectedMessageClicked = onEditSelectedMessageClicked,
onRefresh = onRefresh,
onPinnedMessageClicked = onPinnedMessageClicked,
onUnpinMessageButtonClicked = onUnpinMessageButtonClicked
@@ -211,6 +223,7 @@ fun MessagesHistoryScreen(
) {
MessagesList(
modifier = Modifier.align(Alignment.BottomStart),
screenState = screenState,
hazeState = hazeState,
listState = listState,
hasPinnedMessage = pinnedMessage != null,
@@ -259,12 +272,13 @@ fun MessagesHistoryScreen(
actionMode = screenState.actionMode,
replyTitle = screenState.replyTitle,
replyText = screenState.replyText,
inputFieldFocusRequester = inputFieldFocusRequester,
showKeyboard = showKeyboard,
onSetMessageBarHeight = { messageBarHeight = it },
onEmojiButtonLongClicked = onEmojiButtonLongClicked,
onAttachmentButtonClicked = onAttachmentButtonClicked,
onActionButtonClicked = onActionButtonClicked,
onReplyCloseClicked = onReplyCloseClicked
onReplyCloseClicked = onReplyCloseClicked,
onKeyboardShown = onKeyboardShown
)
when {
@@ -2,6 +2,7 @@ package dev.meloda.fast.messageshistory.presentation
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.Crossfade
import androidx.compose.animation.animateContentSize
import androidx.compose.foundation.Image
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
@@ -31,7 +32,6 @@ import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.painter.Painter
import androidx.compose.ui.platform.LocalView
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource
@@ -44,11 +44,9 @@ import dev.chrisbanes.haze.HazeState
import dev.chrisbanes.haze.hazeEffect
import dev.chrisbanes.haze.materials.ExperimentalHazeMaterialsApi
import dev.chrisbanes.haze.materials.HazeMaterials
import dev.meloda.fast.common.model.UiImage
import dev.meloda.fast.datastore.AppSettings
import dev.meloda.fast.ui.R
import dev.meloda.fast.ui.theme.LocalThemeConfig
import dev.meloda.fast.ui.util.getImage
@OptIn(ExperimentalMaterial3Api::class, ExperimentalHazeMaterialsApi::class)
@Composable
@@ -56,16 +54,20 @@ fun MessagesHistoryTopBar(
modifier: Modifier = Modifier,
hazeState: HazeState,
showReplyAction: Boolean,
showEditAction: Boolean,
isClickable: Boolean,
isMessagesSelecting: Boolean,
isPeerAccount: Boolean,
avatar: UiImage,
avatarUrl: String?,
avatarResourceId: Int?,
title: String,
isEditing: Boolean,
onTopBarClicked: () -> Unit = {},
onBack: () -> Unit = {},
onClose: () -> Unit = {},
onDeleteSelectedButtonClicked: () -> Unit = {},
onRefresh: () -> Unit = {}
onRefresh: () -> Unit = {},
onEditSelectedMessageClicked: () -> Unit = {}
) {
val view = LocalView.current
val theme = LocalThemeConfig.current
@@ -96,50 +98,55 @@ fun MessagesHistoryTopBar(
// modifier = Modifier.weight(1f),
verticalAlignment = Alignment.CenterVertically
) {
if (!isMessagesSelecting) {
if (isPeerAccount) {
Box(
modifier = Modifier
.size(36.dp)
.clip(CircleShape)
.background(MaterialTheme.colorScheme.primary)
) {
Icon(
modifier = Modifier
.align(Alignment.Center)
.size(24.dp),
painter = painterResource(id = R.drawable.ic_bookmark_round_24),
contentDescription = "Favorites icon",
tint = MaterialTheme.colorScheme.onPrimary
)
}
} else {
val actualAvatar = avatar.getImage()
if (actualAvatar is Painter) {
Image(
painter = actualAvatar,
contentDescription = null,
AnimatedVisibility(!isMessagesSelecting && !isEditing) {
Row {
if (isPeerAccount) {
Box(
modifier = Modifier
.size(36.dp)
.clip(CircleShape)
)
.background(MaterialTheme.colorScheme.primary)
) {
Icon(
modifier = Modifier
.align(Alignment.Center)
.size(24.dp),
painter = painterResource(id = R.drawable.ic_bookmark_round_24),
contentDescription = "Favorites icon",
tint = MaterialTheme.colorScheme.onPrimary
)
}
} else {
AsyncImage(
model = actualAvatar,
contentDescription = "Profile Image",
modifier = Modifier
.size(36.dp)
.clip(CircleShape),
placeholder = painterResource(id = R.drawable.ic_account_circle_fill_round_24),
)
}
}
when {
avatarUrl != null -> {
AsyncImage(
model = avatarUrl,
contentDescription = "Profile Image",
modifier = Modifier
.size(36.dp)
.clip(CircleShape),
placeholder = painterResource(id = R.drawable.ic_account_circle_fill_round_24),
)
}
Spacer(modifier = Modifier.width(12.dp))
avatarResourceId != null -> {
Image(
painter = painterResource(avatarResourceId),
contentDescription = null,
modifier = Modifier
.size(36.dp)
.clip(CircleShape)
)
}
}
}
Spacer(modifier = Modifier.width(12.dp))
}
}
Text(
modifier = Modifier.animateContentSize(),
text = title,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
@@ -150,11 +157,11 @@ fun MessagesHistoryTopBar(
navigationIcon = {
IconButton(
onClick = {
if (!isMessagesSelecting) onBack()
if (!isMessagesSelecting && !isEditing) onBack()
else onClose()
}
) {
Crossfade(targetState = !isMessagesSelecting) { state ->
Crossfade(targetState = !isMessagesSelecting && !isEditing) { state ->
Icon(
painter = painterResource(
if (state) {
@@ -210,6 +217,16 @@ fun MessagesHistoryTopBar(
contentDescription = null
)
}
AnimatedVisibility(showEditAction) {
IconButton(onClick = onEditSelectedMessageClicked) {
Icon(
painter = painterResource(R.drawable.ic_edit_round_24),
contentDescription = null
)
}
}
IconButton(onClick = onDeleteSelectedButtonClicked) {
Icon(
painter = painterResource(R.drawable.ic_delete_round_24),
@@ -16,7 +16,6 @@ import dev.chrisbanes.haze.hazeEffect
import dev.chrisbanes.haze.materials.ExperimentalHazeMaterialsApi
import dev.chrisbanes.haze.materials.HazeMaterials
import dev.meloda.fast.common.extensions.orDots
import dev.meloda.fast.common.model.UiImage
import dev.meloda.fast.model.api.domain.VkMessage
import dev.meloda.fast.ui.theme.LocalThemeConfig
@@ -26,13 +25,16 @@ fun MessagesHistoryTopBarContainer(
modifier: Modifier = Modifier,
hazeState: HazeState,
showReplyAction: Boolean,
showEditAction: Boolean,
topBarContainerColor: Color,
topBarContainerColorAlpha: Float,
isClickable: Boolean,
isMessagesSelecting: Boolean,
isPeerAccount: Boolean,
avatar: UiImage,
avatarUrl: String?,
avatarResourceId: Int?,
title: String,
isEditing: Boolean,
showHorizontalProgressBar: Boolean,
showPinnedContainer: Boolean,
pinnedMessage: VkMessage?,
@@ -44,6 +46,7 @@ fun MessagesHistoryTopBarContainer(
onClose: () -> Unit = {},
onDeleteSelectedButtonClicked: () -> Unit = {},
onRefresh: () -> Unit = {},
onEditSelectedMessageClicked: () -> Unit = {},
onPinnedMessageClicked: (Long) -> Unit = {},
onUnpinMessageButtonClicked: () -> Unit = {}
) {
@@ -66,16 +69,20 @@ fun MessagesHistoryTopBarContainer(
modifier = modifier,
hazeState = hazeState,
showReplyAction = showReplyAction,
showEditAction = showEditAction,
isClickable = isClickable,
isMessagesSelecting = isMessagesSelecting,
isPeerAccount = isPeerAccount,
avatar = avatar,
avatarUrl = avatarUrl,
avatarResourceId = avatarResourceId,
title = title,
isEditing = isEditing,
onTopBarClicked = onTopBarClicked,
onBack = onBack,
onClose = onClose,
onDeleteSelectedButtonClicked = onDeleteSelectedButtonClicked,
onRefresh = onRefresh
onRefresh = onRefresh,
onEditSelectedMessageClicked = onEditSelectedMessageClicked
)
if (showHorizontalProgressBar) {
@@ -46,6 +46,7 @@ import androidx.core.view.HapticFeedbackConstantsCompat
import dev.chrisbanes.haze.HazeState
import dev.chrisbanes.haze.hazeSource
import dev.meloda.fast.datastore.AppSettings
import dev.meloda.fast.messageshistory.model.MessagesHistoryScreenState
import dev.meloda.fast.model.api.domain.VkAttachment
import dev.meloda.fast.model.api.domain.VkFileDomain
import dev.meloda.fast.model.api.domain.VkLinkDomain
@@ -60,6 +61,7 @@ import kotlinx.coroutines.launch
@Composable
fun MessagesList(
modifier: Modifier = Modifier,
screenState: MessagesHistoryScreenState,
hasPinnedMessage: Boolean,
hazeState: HazeState,
listState: LazyListState,
@@ -226,47 +228,55 @@ fun MessagesList(
fadeOutSpec = null
) else Modifier
)
.combinedClickable(
onLongClick = {
if (AppSettings.General.enableHaptic) {
view.performHapticFeedback(HapticFeedbackConstants.LONG_PRESS)
}
onMessageLongClicked(item.id)
},
onClick = { onMessageClicked(item.id) }
)
.pointerInput(item.cmId) {
detectHorizontalDragGestures(
onDragCancel = {
if (offsetX == -100f) {
onRequestMessageReply(item.cmId)
}
.then(
if (screenState.editCmId == null) {
Modifier
.combinedClickable(
onLongClick = {
if (AppSettings.General.enableHaptic) {
view.performHapticFeedback(
HapticFeedbackConstants.LONG_PRESS
)
}
onMessageLongClicked(item.id)
},
onClick = { onMessageClicked(item.id) }
)
.pointerInput(item.cmId) {
detectHorizontalDragGestures(
onDragCancel = {
if (offsetX == -100f) {
onRequestMessageReply(item.cmId)
}
scope.launch {
animate = true
offsetX = 0f
offsetAnimatable.animateTo(0f)
animate = false
}
},
onDragEnd = {
if (offsetX == -100f) {
onRequestMessageReply(item.cmId)
}
scope.launch {
animate = true
offsetX = 0f
offsetAnimatable.animateTo(0f)
animate = false
}
},
onDragEnd = {
if (offsetX == -100f) {
onRequestMessageReply(item.cmId)
}
scope.launch {
animate = true
offsetX = 0f
offsetAnimatable.animateTo(0f)
animate = false
scope.launch {
animate = true
offsetX = 0f
offsetAnimatable.animateTo(0f)
animate = false
}
},
onHorizontalDrag = { change, dragAmount ->
change.consume()
offsetX =
(offsetX + dragAmount).coerceIn(-100f, 0f)
}
)
}
},
onHorizontalDrag = { change, dragAmount ->
change.consume()
offsetX = (offsetX + dragAmount).coerceIn(-100f, 0f)
}
)
},
} else Modifier
),
color = backgroundColor
) {
if (item.isOut) {