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
@@ -53,7 +53,7 @@ import dev.meloda.fast.model.BaseError
import dev.meloda.fast.ui.R
import dev.meloda.fast.ui.basic.ContentAlpha
import dev.meloda.fast.ui.basic.LocalContentAlpha
import dev.meloda.fast.ui.components.FullScreenLoader
import dev.meloda.fast.ui.components.FullScreenContainedLoader
import dev.meloda.fast.ui.components.NoItemsView
import dev.meloda.fast.ui.components.VkErrorView
import dev.meloda.fast.ui.theme.LocalHazeState
@@ -105,7 +105,7 @@ fun AudioMaterialsScreen(
VkErrorView(baseError = baseError)
}
screenState.isLoading && screenState.materials.isEmpty() -> FullScreenLoader()
screenState.isLoading && screenState.materials.isEmpty() -> FullScreenContainedLoader()
else -> {
PullToRefreshBox(
@@ -63,7 +63,7 @@ import dev.meloda.fast.model.BaseError
import dev.meloda.fast.ui.R
import dev.meloda.fast.ui.basic.ContentAlpha
import dev.meloda.fast.ui.basic.LocalContentAlpha
import dev.meloda.fast.ui.components.FullScreenLoader
import dev.meloda.fast.ui.components.FullScreenContainedLoader
import dev.meloda.fast.ui.components.NoItemsView
import dev.meloda.fast.ui.components.VkErrorView
import dev.meloda.fast.ui.theme.LocalHazeState
@@ -114,7 +114,7 @@ fun FileMaterialsScreen(
VkErrorView(baseError = baseError)
}
screenState.isLoading && screenState.materials.isEmpty() -> FullScreenLoader()
screenState.isLoading && screenState.materials.isEmpty() -> FullScreenContainedLoader()
else -> {
PullToRefreshBox(
@@ -63,7 +63,7 @@ import dev.meloda.fast.model.BaseError
import dev.meloda.fast.ui.R
import dev.meloda.fast.ui.basic.ContentAlpha
import dev.meloda.fast.ui.basic.LocalContentAlpha
import dev.meloda.fast.ui.components.FullScreenLoader
import dev.meloda.fast.ui.components.FullScreenContainedLoader
import dev.meloda.fast.ui.components.NoItemsView
import dev.meloda.fast.ui.components.VkErrorView
import dev.meloda.fast.ui.theme.LocalHazeState
@@ -114,7 +114,7 @@ fun LinkMaterialsScreen(
VkErrorView(baseError = baseError)
}
screenState.isLoading && screenState.materials.isEmpty() -> FullScreenLoader()
screenState.isLoading && screenState.materials.isEmpty() -> FullScreenContainedLoader()
else -> {
PullToRefreshBox(
@@ -46,7 +46,7 @@ import dev.meloda.fast.chatmaterials.model.ChatMaterialsScreenState
import dev.meloda.fast.chatmaterials.model.UiChatMaterial
import dev.meloda.fast.model.BaseError
import dev.meloda.fast.ui.R
import dev.meloda.fast.ui.components.FullScreenLoader
import dev.meloda.fast.ui.components.FullScreenContainedLoader
import dev.meloda.fast.ui.components.NoItemsView
import dev.meloda.fast.ui.components.VkErrorView
import dev.meloda.fast.ui.theme.LocalHazeState
@@ -98,7 +98,7 @@ fun PhotoMaterialsScreen(
VkErrorView(baseError = baseError)
}
screenState.isLoading && screenState.materials.isEmpty() -> FullScreenLoader()
screenState.isLoading && screenState.materials.isEmpty() -> FullScreenContainedLoader()
else -> {
PullToRefreshBox(
@@ -56,7 +56,7 @@ import dev.meloda.fast.model.BaseError
import dev.meloda.fast.ui.R
import dev.meloda.fast.ui.basic.ContentAlpha
import dev.meloda.fast.ui.basic.LocalContentAlpha
import dev.meloda.fast.ui.components.FullScreenLoader
import dev.meloda.fast.ui.components.FullScreenContainedLoader
import dev.meloda.fast.ui.components.NoItemsView
import dev.meloda.fast.ui.components.VkErrorView
import dev.meloda.fast.ui.theme.LocalHazeState
@@ -107,7 +107,7 @@ fun VideoMaterialsScreen(
VkErrorView(baseError = baseError)
}
screenState.isLoading && screenState.materials.isEmpty() -> FullScreenLoader()
screenState.isLoading && screenState.materials.isEmpty() -> FullScreenContainedLoader()
else -> {
PullToRefreshBox(
@@ -44,7 +44,6 @@ 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
@@ -65,7 +64,7 @@ import dev.chrisbanes.haze.materials.HazeMaterials
import dev.meloda.fast.conversations.model.ConversationsScreenState
import dev.meloda.fast.conversations.navigation.ConversationsGraph
import dev.meloda.fast.model.BaseError
import dev.meloda.fast.ui.components.FullScreenLoader
import dev.meloda.fast.ui.components.FullScreenContainedLoader
import dev.meloda.fast.ui.components.NoItemsView
import dev.meloda.fast.ui.components.VkErrorView
import dev.meloda.fast.ui.model.api.ConversationOption
@@ -306,7 +305,7 @@ fun ConversationsScreen(
)
}
screenState.isLoading && conversations.isEmpty() -> FullScreenLoader()
screenState.isLoading && conversations.isEmpty() -> FullScreenContainedLoader()
else -> {
val pullToRefreshState = rememberPullToRefreshState()
@@ -51,7 +51,6 @@ 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
@@ -64,8 +63,7 @@ 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.FullScreenContainedLoader
import dev.meloda.fast.ui.components.IconButton
import dev.meloda.fast.ui.components.NoItemsView
import dev.meloda.fast.ui.components.VkErrorView
@@ -271,7 +269,7 @@ fun CreateChatScreen(
VkErrorView(baseError = baseError)
}
screenState.isLoading && screenState.friends.isEmpty() -> FullScreenLoader()
screenState.isLoading && screenState.friends.isEmpty() -> FullScreenContainedLoader()
else -> {
val pullToRefreshState = rememberPullToRefreshState()
@@ -1,7 +1,6 @@
package dev.meloda.fast.friends.presentation
import android.content.Context
import androidx.appcompat.app.AppCompatActivity
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.calculateEndPadding
import androidx.compose.foundation.layout.calculateStartPadding
@@ -28,11 +27,9 @@ import coil.imageLoader
import coil.request.ImageRequest
import dev.chrisbanes.haze.hazeSource
import dev.meloda.fast.friends.FriendsViewModel
import dev.meloda.fast.friends.FriendsViewModelImpl
import dev.meloda.fast.friends.OnlineFriendsViewModelImpl
import dev.meloda.fast.friends.navigation.Friends
import dev.meloda.fast.ui.R
import dev.meloda.fast.ui.components.FullScreenLoader
import dev.meloda.fast.ui.components.FullScreenContainedLoader
import dev.meloda.fast.ui.components.NoItemsView
import dev.meloda.fast.ui.components.VkErrorView
import dev.meloda.fast.ui.theme.LocalHazeState
@@ -41,7 +38,6 @@ import dev.meloda.fast.ui.theme.LocalThemeConfig
import dev.meloda.fast.ui.util.ImmutableList
import kotlinx.coroutines.flow.collectLatest
import kotlinx.coroutines.flow.debounce
import org.koin.androidx.compose.koinViewModel
@OptIn(ExperimentalMaterial3Api::class)
@Composable
@@ -136,7 +132,7 @@ fun FriendsScreen(
}
when {
screenState.isLoading && screenState.friends.isEmpty() -> FullScreenLoader()
screenState.isLoading && screenState.friends.isEmpty() -> FullScreenContainedLoader()
else -> {
val pullToRefreshState = rememberPullToRefreshState()
@@ -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 ->
@@ -1,21 +1,43 @@
package dev.meloda.fast.photoviewer
import android.content.ClipData
import android.content.ClipboardManager
import android.content.Context
import android.graphics.Bitmap
import android.widget.Toast
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 dev.meloda.fast.common.extensions.setValue
import dev.meloda.fast.common.model.UiImage
import dev.meloda.fast.photoviewer.model.PhotoViewScreenState
import dev.meloda.fast.photoviewer.navigation.PhotoView
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import java.io.File
import java.io.FileOutputStream
import java.net.URLDecoder
import java.util.UUID
interface PhotoViewViewModel {
val screenState: StateFlow<PhotoViewScreenState>
fun onPageChanged(newPage: Int)
fun onCopyLinkClicked()
fun onCopyClicked()
}
class PhotoViewViewModelImpl(
savedStateHandle: SavedStateHandle
savedStateHandle: SavedStateHandle,
private val applicationContext: Context
) : PhotoViewViewModel, ViewModel() {
override val screenState = MutableStateFlow(PhotoViewScreenState.EMPTY)
@@ -25,10 +47,73 @@ class PhotoViewViewModelImpl(
screenState.setValue { old ->
old.copy(
images = arguments.images
images = arguments.imageUrls
.map { URLDecoder.decode(it, "utf-8") }
.map(UiImage::Url)
.map(UiImage::Url),
selectedPage = arguments.selectedIndex?.takeIf { it != -1 } ?: 0
)
}
}
override fun onPageChanged(newPage: Int) {
screenState.setValue { old -> old.copy(selectedPage = newPage) }
}
override fun onCopyLinkClicked() {
val url = screenState.value.images
.getOrNull(screenState.value.selectedPage)
?.extractUrl() ?: return
val clipboardManager =
applicationContext.getSystemService(Context.CLIPBOARD_SERVICE) as ClipboardManager
clipboardManager.setPrimaryClip(ClipData.newPlainText("URL", url))
Toast.makeText(
applicationContext,
"URL copied to clipboard",
Toast.LENGTH_SHORT
).show()
}
override fun onCopyClicked() {
val clipboardManager =
applicationContext.getSystemService(Context.CLIPBOARD_SERVICE) as ClipboardManager
val url = screenState.value.images
.getOrNull(screenState.value.selectedPage)
?.extractUrl() ?: return
viewModelScope.launch(Dispatchers.IO) {
val drawable = applicationContext.imageLoader.execute(
ImageRequest.Builder(applicationContext)
.data(url)
.build()
).drawable ?: return@launch
val imagesDir = File(applicationContext.cacheDir, "images")
if (!imagesDir.exists()) imagesDir.mkdirs()
val imageFile = File(imagesDir, "shared_image_id${UUID.randomUUID()}.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()
}
}
}
}
@@ -7,5 +7,6 @@ import kotlinx.serialization.Serializable
@Parcelize
@Serializable
data class PhotoViewArguments(
val images: List<String>
val imageUrls: List<String>,
val selectedIndex: Int?
) : Parcelable
@@ -5,12 +5,14 @@ import dev.meloda.fast.common.model.UiImage
@Immutable
data class PhotoViewScreenState(
val images: List<UiImage>
val images: List<UiImage>,
val selectedPage: Int
) {
companion object {
val EMPTY: PhotoViewScreenState = PhotoViewScreenState(
images = emptyList()
images = emptyList(),
selectedPage = 0
)
}
}
@@ -30,11 +30,15 @@ fun NavGraphBuilder.photoViewScreen(
}
}
fun NavController.navigateToPhotoView(images: List<String>) {
fun NavController.navigateToPhotoView(
images: List<String>,
selectedIndex: Int? = null
) {
this.navigate(
PhotoView(
arguments = PhotoViewArguments(
images.map { URLEncoder.encode(it, "utf-8") }
imageUrls = images.map { URLEncoder.encode(it, "utf-8") },
selectedIndex = selectedIndex
)
)
)
@@ -7,6 +7,7 @@ import androidx.compose.foundation.gestures.draggable
import androidx.compose.foundation.gestures.rememberDraggableState
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.defaultMinSize
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.pager.HorizontalPager
@@ -14,27 +15,36 @@ import androidx.compose.foundation.pager.PagerState
import androidx.compose.foundation.pager.rememberPagerState
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.rounded.ArrowBack
import androidx.compose.material.icons.rounded.MoreVert
import androidx.compose.material3.DropdownMenu
import androidx.compose.material3.DropdownMenuItem
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text
import androidx.compose.material3.TopAppBar
import androidx.compose.material3.TopAppBarDefaults
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.derivedStateOf
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableFloatStateOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberUpdatedState
import androidx.compose.runtime.setValue
import androidx.compose.runtime.snapshotFlow
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.graphicsLayer
import androidx.compose.ui.graphics.painter.ColorPainter
import androidx.compose.ui.graphics.painter.Painter
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.DpOffset
import androidx.compose.ui.unit.dp
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import coil.compose.AsyncImage
import com.conena.nanokt.android.content.pxToDp
@@ -56,16 +66,30 @@ fun PhotoViewRoute(
PhotoViewScreen(
screenState = screenState,
onBack = onBack
onBack = onBack,
onPageChanged = viewModel::onPageChanged,
onCopyLinkClicked = viewModel::onCopyLinkClicked,
onCopyClicked = viewModel::onCopyClicked
)
}
@Composable
fun PhotoViewScreen(
screenState: PhotoViewScreenState = PhotoViewScreenState.EMPTY,
onBack: () -> Unit = {}
onBack: () -> Unit = {},
onPageChanged: (index: Int) -> Unit = {},
onCopyLinkClicked: () -> Unit = {},
onCopyClicked: () -> Unit = {}
) {
val pagerState = rememberPagerState(pageCount = { screenState.images.size })
val pagerState = rememberPagerState(
pageCount = { screenState.images.size },
initialPage = screenState.selectedPage
)
LaunchedEffect(pagerState) {
snapshotFlow { pagerState.currentPage }
.collect(onPageChanged)
}
var offsetY by remember { mutableFloatStateOf(0f) }
@@ -81,7 +105,13 @@ fun PhotoViewScreen(
Scaffold(
modifier = Modifier.graphicsLayer(alpha = calculatedAlpha),
topBar = { TopBar(onBack = onBack) },
topBar = {
TopBar(
onBack = onBack,
onCopyClicked = onCopyClicked,
onCopyLinkClicked = onCopyLinkClicked,
)
},
containerColor = MaterialTheme.colorScheme.background.copy(
alpha = calculatedAlpha
)
@@ -103,14 +133,18 @@ fun PhotoViewScreen(
@Composable
fun TopBar(
modifier: Modifier = Modifier,
onBack: () -> Unit
onBack: () -> Unit,
onCopyClicked: () -> Unit,
onCopyLinkClicked: () -> Unit
) {
val context = LocalContext.current
var dropdownMenuShown by remember {
mutableStateOf(false)
}
val hideDropDownMenu by rememberUpdatedState(
{ dropdownMenuShown = false }
)
TopAppBar(
modifier = modifier,
title = {},
@@ -123,29 +157,40 @@ fun TopBar(
}
},
actions = {
// IconButton.kt(
// onClick = { dropdownMenuShown = true }
// ) {
// Icon(
// imageVector = Icons.Rounded.MoreVert,
// contentDescription = "Options"
// )
// }
IconButton(
onClick = { dropdownMenuShown = true }
) {
Icon(
imageVector = Icons.Rounded.MoreVert,
contentDescription = "Options"
)
}
// DropdownMenu(
// modifier = Modifier.defaultMinSize(minWidth = 140.dp),
// expanded = dropdownMenuShown,
// onDismissRequest = { dropdownMenuShown = false },
// offset = DpOffset(x = (10).dp, y = (-60).dp)
// ) {
// DropdownMenuItem(
// onClick = {
// Toast.makeText(context, "Save clicked", Toast.LENGTH_SHORT).show()
// dropdownMenuShown = false
// },
// text = { Text(text = "Save") },
// )
// }
DropdownMenu(
modifier = Modifier.defaultMinSize(minWidth = 140.dp),
expanded = dropdownMenuShown,
onDismissRequest = { dropdownMenuShown = false },
offset = DpOffset(x = (10).dp, y = (-60).dp)
) {
DropdownMenuItem(
onClick = {
hideDropDownMenu()
onCopyLinkClicked()
},
text = {
Text(text = stringResource(UiR.string.action_copy_link))
}
)
DropdownMenuItem(
onClick = {
hideDropDownMenu()
onCopyClicked()
},
text = {
Text(text = stringResource(UiR.string.action_copy_image))
},
)
}
},
colors = TopAppBarDefaults.topAppBarColors(containerColor = Color.Transparent)
)
@@ -230,7 +275,8 @@ private fun PhotoViewScreenPreview() {
screenState = PhotoViewScreenState(
images = List(200) {
UiImage.Resource(UiR.drawable.test_captcha)
}
},
selectedPage = 0
)
)
}
@@ -201,24 +201,12 @@ class SettingsViewModelImpl(
userSettings.onAppLanguageChanged(newLanguage)
}
SettingsKeys.DEFAULT_VALUE_FEATURES_FAST_TEXT -> {
val newText = newValue as? String ?: SettingsKeys.DEFAULT_VALUE_FEATURES_FAST_TEXT
userSettings.onFastTextChanged(newText)
}
SettingsKeys.KEY_ACTIVITY_SEND_ONLINE_STATUS -> {
val isUsing = newValue as? Boolean
?: SettingsKeys.DEFAULT_VALUE_KEY_ACTIVITY_SEND_ONLINE_STATUS
userSettings.onSendOnlineStatusChanged(isUsing)
}
SettingsKeys.KEY_DEBUG_SHOW_CRASH_ALERT -> {
val show = newValue as? Boolean ?: SettingsKeys.DEFAULT_VALUE_KEY_SHOW_EMOJI_BUTTON
userSettings.onShowAlertAfterCrashChanged(show)
}
SettingsKeys.KEY_LONG_POLL_IN_BACKGROUND -> {
val inBackground = newValue as? Boolean
?: SettingsKeys.DEFAULT_LONG_POLL_IN_BACKGROUND
@@ -240,17 +228,6 @@ class SettingsViewModelImpl(
userSettings.onUseBlurChanged(isUsing)
}
SettingsKeys.KEY_SHOW_EMOJI_BUTTON -> {
val show = newValue as? Boolean ?: SettingsKeys.DEFAULT_VALUE_KEY_SHOW_EMOJI_BUTTON
userSettings.onShowEmojiButtonChanged(show)
}
SettingsKeys.KEY_SHOW_TIME_IN_ACTION_MESSAGES -> {
val show = newValue as? Boolean
?: SettingsKeys.DEFAULT_SHOW_TIME_IN_ACTION_MESSAGES
userSettings.onShowTimeInActionMessagesChanged(show)
}
SettingsKeys.KEY_USE_SYSTEM_FONT -> {
val use = newValue as? Boolean ?: SettingsKeys.DEFAULT_USE_SYSTEM_FONT
userSettings.onUseSystemFontChanged(use)
@@ -302,6 +279,12 @@ class SettingsViewModelImpl(
text = UiText.Resource(UiR.string.settings_general_show_emoji_button_summary),
defaultValue = SettingsKeys.DEFAULT_VALUE_KEY_SHOW_EMOJI_BUTTON
)
val generalShowAttachmentButton = SettingsItem.Switch(
key = SettingsKeys.KEY_SHOW_ATTACHMENT_BUTTON,
title = UiText.Resource(UiR.string.settings_general_show_attachment_button_title),
text = UiText.Resource(UiR.string.settings_general_show_attachment_button_summary),
defaultValue = SettingsKeys.DEFAULT_VALUE_SHOW_ATTACHMENT_BUTTON
)
val generalEnableHaptic = SettingsItem.Switch(
key = SettingsKeys.KEY_ENABLE_HAPTIC,
defaultValue = SettingsKeys.DEFAULT_ENABLE_HAPTIC,
@@ -476,6 +459,7 @@ class SettingsViewModelImpl(
generalTitle,
generalUseContactNames,
generalShowEmojiButton,
generalShowAttachmentButton,
generalEnableHaptic
)
val appearanceList = listOf(