Refactor: Introduce FullScreenContainedLoader and use rememberUpdatedState

This commit introduces `FullScreenContainedLoader` and replaces usages of `FullScreenLoader` where appropriate.

It also updates several composables to use `rememberUpdatedState` for lambda parameters to ensure the latest versions are used.

Additionally, the following changes are included:
- Add a setting to show/hide the attachment button in the chat input bar.
- Implement navigation to `PhotoViewScreen` when a photo attachment is clicked in a message.
- Add "Copy link" and "Copy image" actions to `PhotoViewScreen`.
- Remove unused settings and their corresponding logic from `SettingsViewModel` and `UserSettings`.
This commit is contained in:
2025-06-24 14:44:51 +03:00
parent c1e76e1c60
commit 3dae1fe101
29 changed files with 406 additions and 192 deletions
@@ -3,6 +3,7 @@ package dev.meloda.fast.messageshistory
import android.content.ClipData
import android.content.ClipboardManager
import android.content.Context
import android.graphics.Bitmap
import android.os.Build
import android.os.Bundle
import android.util.Log
@@ -15,9 +16,13 @@ import androidx.compose.ui.text.font.FontStyle
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.input.TextFieldValue
import androidx.compose.ui.text.style.TextDecoration
import androidx.core.content.FileProvider
import androidx.core.graphics.drawable.toBitmapOrNull
import androidx.lifecycle.SavedStateHandle
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import coil.imageLoader
import coil.request.ImageRequest
import com.conena.nanokt.collections.indexOfFirstOrNull
import com.conena.nanokt.text.isEmptyOrBlank
import com.conena.nanokt.text.isNotEmptyOrBlank
@@ -52,12 +57,16 @@ 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
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
import java.io.File
import java.io.FileOutputStream
import kotlin.math.abs
import kotlin.random.Random
import dev.meloda.fast.ui.R as UiR
@@ -163,10 +172,6 @@ class MessagesHistoryViewModelImpl(
updatesParser.onMessageMarkedAsImportant(::handleMessageMarkedAsImportant)
updatesParser.onMessageMarkedAsSpam(::handleMessageMarkedAsSpam)
updatesParser.onMessageMarkedAsNotSpam(::handleMessageMarkedAsNotSpam)
userSettings.showTimeInActionMessages.listenValue(viewModelScope) {
syncUiMessages()
}
}
override fun onNavigationConsumed() {
@@ -1131,13 +1136,54 @@ class MessagesHistoryViewModelImpl(
}
private fun copyMessage(message: VkMessage) {
val contentToCopy = message.text.orEmpty().trim()
if (contentToCopy.isEmpty()) return
val clipboardManager =
applicationContext.getSystemService(Context.CLIPBOARD_SERVICE) as ClipboardManager
clipboardManager.setPrimaryClip(ClipData.newPlainText("Message", contentToCopy))
val messageToCopy = message.text.orEmpty().trim()
if (messageToCopy.isEmpty()) {
val photo = with(message.attachments.orEmpty()) {
if (size == 1 && all { it is VkPhotoDomain }) {
first() as? VkPhotoDomain
} else null
} ?: return
val photoMaxSize = photo.getMaxSize() ?: return
viewModelScope.launch(Dispatchers.IO) {
val drawable = applicationContext.imageLoader.execute(
ImageRequest.Builder(applicationContext)
.data(photoMaxSize.url)
.build()
).drawable ?: return@launch
val imagesDir = File(applicationContext.cacheDir, "images")
if (!imagesDir.exists()) imagesDir.mkdirs()
val imageFile = File(imagesDir, "shared_image_id${photo.id}.png")
FileOutputStream(imageFile).use {
drawable.toBitmapOrNull()?.compress(Bitmap.CompressFormat.PNG, 100, it)
}
val uri = FileProvider.getUriForFile(
applicationContext,
"${applicationContext.packageName}.provider",
imageFile
)
val clip = ClipData.newUri(applicationContext.contentResolver, "Image", uri)
clipboardManager.setPrimaryClip(clip)
withContext(Dispatchers.Main) {
Toast.makeText(
applicationContext,
"Image copied to clipboard",
Toast.LENGTH_SHORT
).show()
}
}
return
}
clipboardManager.setPrimaryClip(ClipData.newPlainText("Message", messageToCopy))
if (Build.VERSION.SDK_INT <= Build.VERSION_CODES.S) {
Toast.makeText(applicationContext, UiR.string.copied_to_clipboard, Toast.LENGTH_SHORT)
@@ -1155,7 +1201,7 @@ class MessagesHistoryViewModelImpl(
showName = false,
prevMessage = messages.getOrNull(index + 1),
nextMessage = messages.getOrNull(index - 1),
showTimeInActionMessages = userSettings.showTimeInActionMessages.value,
showTimeInActionMessages = AppSettings.Experimental.showTimeInActionMessages,
conversation = screenState.value.conversation,
isSelected = selectedMessages.indexOfFirstOrNull { it.id == message.id } != null
)
@@ -5,6 +5,7 @@ import androidx.navigation.NavController
import androidx.navigation.NavGraphBuilder
import androidx.navigation.compose.composable
import androidx.navigation.toRoute
import dev.meloda.fast.common.model.UiImage
import dev.meloda.fast.messageshistory.model.MessagesHistoryArguments
import dev.meloda.fast.messageshistory.presentation.MessagesHistoryRoute
import dev.meloda.fast.model.BaseError
@@ -27,13 +28,15 @@ data class MessagesHistory(val arguments: MessagesHistoryArguments) {
fun NavGraphBuilder.messagesHistoryScreen(
onError: (BaseError) -> Unit,
onBack: () -> Unit,
onNavigateToChatMaterials: (peerId: Long, cmId: Long) -> Unit
onNavigateToChatMaterials: (peerId: Long, cmId: Long) -> Unit,
onNavigateToPhotoViewer: (images: List<String>, index: Int) -> Unit,
) {
composable<MessagesHistory>(typeMap = MessagesHistory.typeMap) {
MessagesHistoryRoute(
onError = onError,
onBack = onBack,
onNavigateToChatMaterials = onNavigateToChatMaterials
onNavigateToChatMaterials = onNavigateToChatMaterials,
onNavigateToPhotoViewer = onNavigateToPhotoViewer
)
}
}
@@ -16,6 +16,8 @@ import androidx.compose.foundation.shape.CircleShape
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.rememberUpdatedState
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.alpha
@@ -38,6 +40,9 @@ fun IncomingMessageBubble(
onClick: (VkAttachment) -> Unit = {},
onLongClick: (VkAttachment) -> Unit = {}
) {
val currentOnClick by rememberUpdatedState(onClick)
val currentOnLongClick by rememberUpdatedState(onLongClick)
Row(
modifier = modifier
.fillMaxWidth()
@@ -98,8 +103,8 @@ fun IncomingMessageBubble(
isImportant = message.isImportant,
isSelected = message.isSelected,
attachments = message.attachments?.toImmutableList(),
onClick = onClick,
onLongClick = onLongClick
onClick = currentOnClick,
onLongClick = currentOnLongClick
)
}
}
@@ -18,6 +18,7 @@ import androidx.compose.runtime.derivedStateOf
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberUpdatedState
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
@@ -50,6 +51,9 @@ fun MessageBubble(
onClick: (VkAttachment) -> Unit = {},
onLongClick: (VkAttachment) -> Unit = {}
) {
val currentOnClick by rememberUpdatedState(onClick)
val currentOnLongClick by rememberUpdatedState(onLongClick)
val theme = LocalThemeConfig.current
val backgroundColor = if (!isOut) {
MaterialTheme.colorScheme.surfaceColorAtElevation(2.dp)
@@ -173,8 +177,8 @@ fun MessageBubble(
Attachments(
modifier = Modifier,
attachments = attachments,
onClick = onClick,
onLongClick = onLongClick
onClick = currentOnClick,
onLongClick = currentOnLongClick
)
val dateStatusBackground = if (theme.darkMode) Color.Black.copy(alpha = 0.5f)
@@ -63,7 +63,9 @@ fun MessagesHistoryInputBar(
modifier: Modifier = Modifier,
message: TextFieldValue,
hazeState: HazeState,
enableHaptic: Boolean,
showEmojiButton: Boolean,
showAttachmentButton: Boolean,
actionMode: ActionMode,
onMessageInputChanged: (TextFieldValue) -> Unit = {},
onBoldRequested: () -> Unit = {},
@@ -179,7 +181,7 @@ fun MessagesHistoryInputBar(
}
TextField(
modifier = modifier
modifier = Modifier
.weight(1f)
.addTextContextMenuComponents {
separator()
@@ -236,46 +238,47 @@ fun MessagesHistoryInputBar(
maxLines = 1,
overflow = TextOverflow.Ellipsis
)
}
},
)
if (showAttachmentButton) {
val attachmentRotation = remember { Animatable(0f) }
val attachmentRotation = remember { Animatable(0f) }
Column(verticalArrangement = Arrangement.Bottom) {
IconButton(
onClick = {
onAttachmentButtonClicked()
if (AppSettings.General.enableHaptic) {
view.performHapticFeedback(
HapticFeedbackConstantsCompat.REJECT
)
}
scope.launch {
for (i in 20 downTo 0 step 4) {
attachmentRotation.animateTo(
targetValue = i.toFloat(),
animationSpec = tween(50)
Column(verticalArrangement = Arrangement.Bottom) {
IconButton(
onClick = {
onAttachmentButtonClicked()
if (enableHaptic) {
view.performHapticFeedback(
HapticFeedbackConstantsCompat.REJECT
)
if (i > 0) {
}
scope.launch {
for (i in 20 downTo 0 step 4) {
attachmentRotation.animateTo(
targetValue = -i.toFloat(),
targetValue = i.toFloat(),
animationSpec = tween(50)
)
if (i > 0) {
attachmentRotation.animateTo(
targetValue = -i.toFloat(),
animationSpec = tween(50)
)
}
}
}
}
) {
Icon(
painter = painterResource(id = UiR.drawable.round_attach_file_24),
contentDescription = "Add attachment button",
tint = MaterialTheme.colorScheme.primary,
modifier = Modifier.rotate(30f + attachmentRotation.value)
)
}
) {
Icon(
painter = painterResource(id = UiR.drawable.round_attach_file_24),
contentDescription = "Add attachment button",
tint = MaterialTheme.colorScheme.primary,
modifier = Modifier.rotate(30f + attachmentRotation.value)
)
}
Spacer(modifier = Modifier.height(4.dp))
Spacer(modifier = Modifier.height(4.dp))
}
}
val micRotation = remember { Animatable(0f) }
@@ -4,20 +4,21 @@ import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import dev.meloda.fast.datastore.UserSettings
import dev.meloda.fast.common.model.UiImage
import dev.meloda.fast.datastore.AppSettings
import dev.meloda.fast.messageshistory.MessagesHistoryViewModel
import dev.meloda.fast.messageshistory.MessagesHistoryViewModelImpl
import dev.meloda.fast.messageshistory.model.MessageNavigation
import dev.meloda.fast.model.BaseError
import dev.meloda.fast.ui.util.ImmutableList.Companion.toImmutableList
import org.koin.androidx.compose.koinViewModel
import org.koin.compose.koinInject
@Composable
fun MessagesHistoryRoute(
onError: (BaseError) -> Unit,
onBack: () -> Unit,
onNavigateToChatMaterials: (peerId: Long, conversationMessageId: Long) -> Unit,
onNavigateToPhotoViewer: (images: List<String>, index: Int) -> Unit,
viewModel: MessagesHistoryViewModel = koinViewModel<MessagesHistoryViewModelImpl>()
) {
val screenState by viewModel.screenState.collectAsStateWithLifecycle()
@@ -30,9 +31,6 @@ fun MessagesHistoryRoute(
val canPaginate by viewModel.canPaginate.collectAsStateWithLifecycle()
val scrollIndex by viewModel.isNeedToScrollToIndex.collectAsStateWithLifecycle()
val userSettings: UserSettings = koinInject()
val showEmojiButton by userSettings.showEmojiButton.collectAsStateWithLifecycle()
LaunchedEffect(navigationEvent) {
val needToConsume = when (val navigation = navigationEvent) {
null -> false
@@ -55,7 +53,9 @@ fun MessagesHistoryRoute(
selectedMessages = selectedMessages.toImmutableList(),
baseError = baseError,
canPaginate = canPaginate,
showEmojiButton = showEmojiButton,
showEmojiButton = AppSettings.General.showEmojiButton,
showAttachmentButton = AppSettings.General.showAttachmentButton,
enableHaptic = AppSettings.General.enableHaptic,
onBack = onBack,
onClose = viewModel::onCloseButtonClicked,
onScrolledToIndex = viewModel::onScrolledToIndex,
@@ -69,6 +69,7 @@ fun MessagesHistoryRoute(
onEmojiButtonLongClicked = viewModel::onEmojiButtonLongClicked,
onMessageClicked = viewModel::onMessageClicked,
onMessageLongClicked = viewModel::onMessageLongClicked,
onPhotoClicked = onNavigateToPhotoViewer,
onPinnedMessageClicked = viewModel::onPinnedMessageClicked,
onUnpinMessageButtonClicked = viewModel::onUnpinMessageClicked,
onDeleteSelectedButtonClicked = viewModel::onDeleteSelectedMessagesClicked,
@@ -14,7 +14,6 @@ import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.statusBars
import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Scaffold
@@ -45,6 +44,7 @@ import dev.meloda.fast.messageshistory.model.UiItem
import dev.meloda.fast.messageshistory.util.indexOfMessageByCmId
import dev.meloda.fast.model.BaseError
import dev.meloda.fast.model.api.domain.VkMessage
import dev.meloda.fast.ui.components.Loader
import dev.meloda.fast.ui.components.VkErrorView
import dev.meloda.fast.ui.theme.LocalThemeConfig
import dev.meloda.fast.ui.util.ImmutableList
@@ -68,6 +68,8 @@ fun MessagesHistoryScreen(
baseError: BaseError? = null,
canPaginate: Boolean = false,
showEmojiButton: Boolean = false,
showAttachmentButton: Boolean = false,
enableHaptic: Boolean = false,
onBack: () -> Unit = {},
onClose: () -> Unit = {},
onScrolledToIndex: () -> Unit = {},
@@ -81,6 +83,7 @@ fun MessagesHistoryScreen(
onEmojiButtonLongClicked: () -> Unit = {},
onMessageClicked: (Long) -> Unit = {},
onMessageLongClicked: (Long) -> Unit = {},
onPhotoClicked: (images: List<String>, index: Int) -> Unit = { _, _ -> },
onPinnedMessageClicked: (Long) -> Unit = {},
onUnpinMessageButtonClicked: () -> Unit = {},
onDeleteSelectedButtonClicked: () -> Unit = {},
@@ -231,7 +234,8 @@ fun MessagesHistoryScreen(
}
currentOnMessageClicked.invoke(id)
},
onMessageLongClicked = onMessageLongClicked
onMessageLongClicked = onMessageLongClicked,
onPhotoClicked = onPhotoClicked
)
MessagesHistoryInputBar(
@@ -244,7 +248,9 @@ fun MessagesHistoryScreen(
onLinkRequested = onLinkRequested,
onRegularRequested = onRegularRequested,
hazeState = hazeState,
enableHaptic = enableHaptic,
showEmojiButton = showEmojiButton,
showAttachmentButton = showAttachmentButton,
actionMode = screenState.actionMode,
onSetMessageBarHeight = { messageBarHeight = it },
onEmojiButtonLongClicked = onEmojiButtonLongClicked,
@@ -254,7 +260,7 @@ fun MessagesHistoryScreen(
when {
screenState.isLoading && messages.values.isEmpty() -> {
CircularProgressIndicator(modifier = Modifier.align(Alignment.Center))
Loader(modifier = Modifier.align(Alignment.Center))
}
baseError != null -> {
@@ -23,7 +23,7 @@ import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberUpdatedState
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
@@ -56,25 +56,36 @@ fun MessagesList(
messageBarHeight: Dp,
onRequestScrollToCmId: (cmId: Long) -> Unit = {},
onMessageClicked: (Long) -> Unit = {},
onMessageLongClicked: (Long) -> Unit = {}
onMessageLongClicked: (Long) -> Unit = {},
onPhotoClicked: (images: List<String>, index: Int) -> Unit = { _, _ -> }
) {
val context = LocalContext.current
val theme = LocalThemeConfig.current
val view = LocalView.current
val onAttachmentClick = remember {
val onAttachmentClick by rememberUpdatedState(
{ message: UiItem.Message, attachment: VkAttachment ->
if (isSelectedAtLeastOne) {
onMessageClicked(message.id)
} else {
when (attachment) {
is VkPhotoDomain -> {
val maxSize = attachment.getMaxSize()
maxSize?.let {
context.startActivity(
Intent(Intent.ACTION_VIEW, maxSize.url.toUri())
)
}
val photos = message.attachments
.orEmpty()
.filterIsInstance<VkPhotoDomain>()
.mapNotNull { photo -> photo.getMaxSize()?.url }
onPhotoClicked(
photos,
photos.indexOfFirst { it == attachment.getMaxSize()?.url }
)
// val maxSize = attachment.getMaxSize()
// maxSize?.let {
// context.startActivity(
// Intent(Intent.ACTION_VIEW, maxSize.url.toUri())
// )
// }
}
is VkFileDomain -> {
@@ -91,9 +102,9 @@ fun MessagesList(
}
}
}
}
)
val onAttachmentLongClick = remember {
val onAttachmentLongClick by rememberUpdatedState(
{ message: UiItem.Message, attachment: VkAttachment ->
if (isSelectedAtLeastOne) {
onMessageLongClicked(message.id)
@@ -107,7 +118,7 @@ fun MessagesList(
}
}
}
}
)
LazyColumn(
modifier = modifier
@@ -200,6 +211,7 @@ fun MessagesList(
),
message = item,
onClick = { attachment ->
onAttachmentClick(item, attachment)
},
onLongClick = { attachment ->