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
@@ -144,7 +144,8 @@ fun RootScreen(
messagesHistoryScreen( messagesHistoryScreen(
onError = viewModel::onError, onError = viewModel::onError,
onBack = navController::navigateUp, onBack = navController::navigateUp,
onNavigateToChatMaterials = navController::navigateToChatMaterials onNavigateToChatMaterials = navController::navigateToChatMaterials,
onNavigateToPhotoViewer = navController::navigateToPhotoView
) )
chatMaterialsScreen( chatMaterialsScreen(
onBack = navController::navigateUp, onBack = navController::navigateUp,
@@ -96,6 +96,13 @@ object AppSettings {
) )
set(value) = put(SettingsKeys.KEY_SHOW_EMOJI_BUTTON, value) set(value) = put(SettingsKeys.KEY_SHOW_EMOJI_BUTTON, value)
var showAttachmentButton: Boolean
get() = get(
SettingsKeys.KEY_SHOW_ATTACHMENT_BUTTON,
SettingsKeys.DEFAULT_VALUE_SHOW_ATTACHMENT_BUTTON
)
set(value) = put(SettingsKeys.KEY_SHOW_ATTACHMENT_BUTTON, value)
var enableHaptic: Boolean var enableHaptic: Boolean
get() = get( get() = get(
SettingsKeys.KEY_ENABLE_HAPTIC, SettingsKeys.KEY_ENABLE_HAPTIC,
@@ -11,6 +11,10 @@ object SettingsKeys {
const val DEFAULT_VALUE_USE_CONTACT_NAMES = false const val DEFAULT_VALUE_USE_CONTACT_NAMES = false
const val KEY_SHOW_EMOJI_BUTTON = "show_emoji_button" const val KEY_SHOW_EMOJI_BUTTON = "show_emoji_button"
const val DEFAULT_VALUE_KEY_SHOW_EMOJI_BUTTON = false const val DEFAULT_VALUE_KEY_SHOW_EMOJI_BUTTON = false
const val KEY_SHOW_ATTACHMENT_BUTTON = "show_attachment_button"
const val DEFAULT_VALUE_SHOW_ATTACHMENT_BUTTON = false
const val KEY_SHOW_RECORD_VOICE_BUTTON = "show_record_voice_button"
const val DEFAULT_VALUE_SHOW_RECORD_VOICE_BUTTON = false
const val KEY_APPEARANCE = "appearance" const val KEY_APPEARANCE = "appearance"
const val KEY_APPEARANCE_MULTILINE = "appearance_multiline" const val KEY_APPEARANCE_MULTILINE = "appearance_multiline"
@@ -14,15 +14,10 @@ interface UserSettings {
val enableDynamicColors: StateFlow<Boolean> val enableDynamicColors: StateFlow<Boolean>
val appLanguage: StateFlow<String> val appLanguage: StateFlow<String>
val fastText: StateFlow<String>
val sendOnlineStatus: StateFlow<Boolean> val sendOnlineStatus: StateFlow<Boolean>
val showAlertAfterCrash: StateFlow<Boolean>
val longPollInBackground: StateFlow<Boolean> val longPollInBackground: StateFlow<Boolean>
val useBlur: StateFlow<Boolean> val useBlur: StateFlow<Boolean>
val showEmojiButton: StateFlow<Boolean>
val showTimeInActionMessages: StateFlow<Boolean>
val useSystemFont: StateFlow<Boolean> val useSystemFont: StateFlow<Boolean>
val enableAnimations: StateFlow<Boolean> val enableAnimations: StateFlow<Boolean>
val showDebugCategory: StateFlow<Boolean> val showDebugCategory: StateFlow<Boolean>
@@ -35,15 +30,10 @@ interface UserSettings {
fun onEnableDynamicColorsChanged(enable: Boolean) fun onEnableDynamicColorsChanged(enable: Boolean)
fun onAppLanguageChanged(language: String) fun onAppLanguageChanged(language: String)
fun onFastTextChanged(text: String)
fun onSendOnlineStatusChanged(send: Boolean) fun onSendOnlineStatusChanged(send: Boolean)
fun onShowAlertAfterCrashChanged(show: Boolean)
fun onLongPollInBackgroundChanged(inBackground: Boolean) fun onLongPollInBackgroundChanged(inBackground: Boolean)
fun onUseBlurChanged(use: Boolean) fun onUseBlurChanged(use: Boolean)
fun onShowEmojiButtonChanged(show: Boolean)
fun onShowTimeInActionMessagesChanged(show: Boolean)
fun onUseSystemFontChanged(use: Boolean) fun onUseSystemFontChanged(use: Boolean)
fun onShowDebugCategoryChanged(show: Boolean) fun onShowDebugCategoryChanged(show: Boolean)
} }
@@ -58,16 +48,11 @@ class UserSettingsImpl : UserSettings {
override val enableDynamicColors = MutableStateFlow(AppSettings.Appearance.enableDynamicColors) override val enableDynamicColors = MutableStateFlow(AppSettings.Appearance.enableDynamicColors)
override val appLanguage = MutableStateFlow(AppSettings.Appearance.appLanguage) override val appLanguage = MutableStateFlow(AppSettings.Appearance.appLanguage)
override val fastText = MutableStateFlow(AppSettings.Features.fastText)
override val sendOnlineStatus = MutableStateFlow(AppSettings.Activity.sendOnlineStatus) override val sendOnlineStatus = MutableStateFlow(AppSettings.Activity.sendOnlineStatus)
override val showAlertAfterCrash = MutableStateFlow(AppSettings.Debug.showAlertAfterCrash) override val longPollInBackground =
override val longPollInBackground = MutableStateFlow(AppSettings.Experimental.longPollInBackground) MutableStateFlow(AppSettings.Experimental.longPollInBackground)
override val useBlur = MutableStateFlow(AppSettings.Experimental.useBlur) override val useBlur = MutableStateFlow(AppSettings.Experimental.useBlur)
override val showEmojiButton = MutableStateFlow(AppSettings.General.showEmojiButton)
override val showTimeInActionMessages =
MutableStateFlow(AppSettings.Experimental.showTimeInActionMessages)
override val useSystemFont = MutableStateFlow(AppSettings.Appearance.useSystemFont) override val useSystemFont = MutableStateFlow(AppSettings.Appearance.useSystemFont)
override val enableAnimations = MutableStateFlow(AppSettings.Experimental.moreAnimations) override val enableAnimations = MutableStateFlow(AppSettings.Experimental.moreAnimations)
override val showDebugCategory = MutableStateFlow(AppSettings.Debug.showDebugCategory) override val showDebugCategory = MutableStateFlow(AppSettings.Debug.showDebugCategory)
@@ -96,18 +81,10 @@ class UserSettingsImpl : UserSettings {
appLanguage.value = language appLanguage.value = language
} }
override fun onFastTextChanged(text: String) {
fastText.value = text
}
override fun onSendOnlineStatusChanged(send: Boolean) { override fun onSendOnlineStatusChanged(send: Boolean) {
sendOnlineStatus.value = send sendOnlineStatus.value = send
} }
override fun onShowAlertAfterCrashChanged(show: Boolean) {
showAlertAfterCrash.value = show
}
override fun onLongPollInBackgroundChanged(inBackground: Boolean) { override fun onLongPollInBackgroundChanged(inBackground: Boolean) {
longPollInBackground.value = inBackground longPollInBackground.value = inBackground
} }
@@ -116,14 +93,6 @@ class UserSettingsImpl : UserSettings {
useBlur.value = use useBlur.value = use
} }
override fun onShowEmojiButtonChanged(show: Boolean) {
showEmojiButton.value = show
}
override fun onShowTimeInActionMessagesChanged(show: Boolean) {
showTimeInActionMessages.value = show
}
override fun onUseSystemFontChanged(use: Boolean) { override fun onUseSystemFontChanged(use: Boolean) {
useSystemFont.value = use useSystemFont.value = use
} }
@@ -5,13 +5,27 @@ import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.navigationBarsPadding import androidx.compose.foundation.layout.navigationBarsPadding
import androidx.compose.material3.ContainedLoadingIndicator import androidx.compose.material3.ContainedLoadingIndicator
import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi
import androidx.compose.material3.LoadingIndicator
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.tooling.preview.Preview
@OptIn(ExperimentalMaterial3ExpressiveApi::class) @Composable
@Preview
fun FullScreenContainedLoader(modifier: Modifier = Modifier) {
Box(
modifier = modifier
.fillMaxSize()
.navigationBarsPadding(),
contentAlignment = Alignment.Center
) {
ContainedLoader()
}
}
@Preview
@Composable @Composable
fun FullScreenLoader(modifier: Modifier = Modifier) { fun FullScreenLoader(modifier: Modifier = Modifier) {
Box( Box(
@@ -20,15 +34,28 @@ fun FullScreenLoader(modifier: Modifier = Modifier) {
.navigationBarsPadding(), .navigationBarsPadding(),
contentAlignment = Alignment.Center contentAlignment = Alignment.Center
) { ) {
ContainedLoadingIndicator( Loader()
containerColor = MaterialTheme.colorScheme.primary,
indicatorColor = MaterialTheme.colorScheme.primaryContainer
)
} }
} }
@Preview @OptIn(ExperimentalMaterial3ExpressiveApi::class)
@Composable @Composable
private fun FullScreenLoaderPreview() { @Preview
FullScreenLoader() fun ContainedLoader(modifier: Modifier = Modifier) {
ContainedLoadingIndicator(
modifier = modifier,
containerColor = MaterialTheme.colorScheme.primary,
indicatorColor = MaterialTheme.colorScheme.primaryContainer
)
} }
@OptIn(ExperimentalMaterial3ExpressiveApi::class)
@Composable
@Preview
fun Loader(modifier: Modifier = Modifier) {
LoadingIndicator(
modifier = modifier,
color = MaterialTheme.colorScheme.primary
)
}
@@ -194,6 +194,7 @@
<string name="settings_general_title">Основное</string> <string name="settings_general_title">Основное</string>
<string name="settings_general_contact_names_title">Использовать имена контактов</string> <string name="settings_general_contact_names_title">Использовать имена контактов</string>
<string name="settings_general_contact_names_summary">Приложение будет использовать доступные имена контактов для пользователей</string> <string name="settings_general_contact_names_summary">Приложение будет использовать доступные имена контактов для пользователей</string>
<string name="settings_general_show_attachment_button_summary">Показывать кнопку вложений на панели чата</string>
<string name="settings_general_enable_haptic_title">Включить тактильную отдачу</string> <string name="settings_general_enable_haptic_title">Включить тактильную отдачу</string>
<string name="settings_appearance_title">Внешний вид</string> <string name="settings_appearance_title">Внешний вид</string>
<string name="settings_appearance_multiline_title">Многострочные заголовки и сообщения</string> <string name="settings_appearance_multiline_title">Многострочные заголовки и сообщения</string>
@@ -272,4 +273,8 @@
<string name="regular">Обычный</string> <string name="regular">Обычный</string>
<string name="login_sign_up">Регистрация</string> <string name="login_sign_up">Регистрация</string>
<string name="login_forgot_password">Забыли пароль?</string> <string name="login_forgot_password">Забыли пароль?</string>
<string name="settings_general_show_attachment_button_title">Показывать кнопку вложений</string>
<string name="action_copy_link">Скопировать ссылку</string>
<string name="action_copy">Скопировать</string>
<string name="action_copy_image">Скопировать изображение</string>
</resources> </resources>
+6
View File
@@ -260,6 +260,8 @@
<string name="settings_general_contact_names_summary">App will use available contact names for users</string> <string name="settings_general_contact_names_summary">App will use available contact names for users</string>
<string name="settings_general_show_emoji_button_title">Show emoji button</string> <string name="settings_general_show_emoji_button_title">Show emoji button</string>
<string name="settings_general_show_emoji_button_summary">Show emoji button in chat panel</string> <string name="settings_general_show_emoji_button_summary">Show emoji button in chat panel</string>
<string name="settings_general_show_attachment_button_title">Show attachment button</string>
<string name="settings_general_show_attachment_button_summary">Show attachment button in chat panel</string>
<string name="settings_general_enable_haptic_title">Enable haptic</string> <string name="settings_general_enable_haptic_title">Enable haptic</string>
<string name="settings_appearance_title">Appearance</string> <string name="settings_appearance_title">Appearance</string>
<string name="settings_appearance_multiline_title">Multiline titles and messages</string> <string name="settings_appearance_multiline_title">Multiline titles and messages</string>
@@ -348,4 +350,8 @@
<string name="regular">Regular</string> <string name="regular">Regular</string>
<string name="login_sign_up">Sign up</string> <string name="login_sign_up">Sign up</string>
<string name="login_forgot_password">Forgot password?</string> <string name="login_forgot_password">Forgot password?</string>
<string name="action_copy_link">Copy link</string>
<string name="action_copy">Copy</string>
<string name="action_copy_image">Copy image</string>
</resources> </resources>
@@ -53,7 +53,7 @@ import dev.meloda.fast.model.BaseError
import dev.meloda.fast.ui.R import dev.meloda.fast.ui.R
import dev.meloda.fast.ui.basic.ContentAlpha import dev.meloda.fast.ui.basic.ContentAlpha
import dev.meloda.fast.ui.basic.LocalContentAlpha import dev.meloda.fast.ui.basic.LocalContentAlpha
import dev.meloda.fast.ui.components.FullScreenLoader import dev.meloda.fast.ui.components.FullScreenContainedLoader
import dev.meloda.fast.ui.components.NoItemsView import dev.meloda.fast.ui.components.NoItemsView
import dev.meloda.fast.ui.components.VkErrorView import dev.meloda.fast.ui.components.VkErrorView
import dev.meloda.fast.ui.theme.LocalHazeState import dev.meloda.fast.ui.theme.LocalHazeState
@@ -105,7 +105,7 @@ fun AudioMaterialsScreen(
VkErrorView(baseError = baseError) VkErrorView(baseError = baseError)
} }
screenState.isLoading && screenState.materials.isEmpty() -> FullScreenLoader() screenState.isLoading && screenState.materials.isEmpty() -> FullScreenContainedLoader()
else -> { else -> {
PullToRefreshBox( PullToRefreshBox(
@@ -63,7 +63,7 @@ import dev.meloda.fast.model.BaseError
import dev.meloda.fast.ui.R import dev.meloda.fast.ui.R
import dev.meloda.fast.ui.basic.ContentAlpha import dev.meloda.fast.ui.basic.ContentAlpha
import dev.meloda.fast.ui.basic.LocalContentAlpha import dev.meloda.fast.ui.basic.LocalContentAlpha
import dev.meloda.fast.ui.components.FullScreenLoader import dev.meloda.fast.ui.components.FullScreenContainedLoader
import dev.meloda.fast.ui.components.NoItemsView import dev.meloda.fast.ui.components.NoItemsView
import dev.meloda.fast.ui.components.VkErrorView import dev.meloda.fast.ui.components.VkErrorView
import dev.meloda.fast.ui.theme.LocalHazeState import dev.meloda.fast.ui.theme.LocalHazeState
@@ -114,7 +114,7 @@ fun FileMaterialsScreen(
VkErrorView(baseError = baseError) VkErrorView(baseError = baseError)
} }
screenState.isLoading && screenState.materials.isEmpty() -> FullScreenLoader() screenState.isLoading && screenState.materials.isEmpty() -> FullScreenContainedLoader()
else -> { else -> {
PullToRefreshBox( PullToRefreshBox(
@@ -63,7 +63,7 @@ import dev.meloda.fast.model.BaseError
import dev.meloda.fast.ui.R import dev.meloda.fast.ui.R
import dev.meloda.fast.ui.basic.ContentAlpha import dev.meloda.fast.ui.basic.ContentAlpha
import dev.meloda.fast.ui.basic.LocalContentAlpha import dev.meloda.fast.ui.basic.LocalContentAlpha
import dev.meloda.fast.ui.components.FullScreenLoader import dev.meloda.fast.ui.components.FullScreenContainedLoader
import dev.meloda.fast.ui.components.NoItemsView import dev.meloda.fast.ui.components.NoItemsView
import dev.meloda.fast.ui.components.VkErrorView import dev.meloda.fast.ui.components.VkErrorView
import dev.meloda.fast.ui.theme.LocalHazeState import dev.meloda.fast.ui.theme.LocalHazeState
@@ -114,7 +114,7 @@ fun LinkMaterialsScreen(
VkErrorView(baseError = baseError) VkErrorView(baseError = baseError)
} }
screenState.isLoading && screenState.materials.isEmpty() -> FullScreenLoader() screenState.isLoading && screenState.materials.isEmpty() -> FullScreenContainedLoader()
else -> { else -> {
PullToRefreshBox( PullToRefreshBox(
@@ -46,7 +46,7 @@ import dev.meloda.fast.chatmaterials.model.ChatMaterialsScreenState
import dev.meloda.fast.chatmaterials.model.UiChatMaterial import dev.meloda.fast.chatmaterials.model.UiChatMaterial
import dev.meloda.fast.model.BaseError import dev.meloda.fast.model.BaseError
import dev.meloda.fast.ui.R 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.NoItemsView
import dev.meloda.fast.ui.components.VkErrorView import dev.meloda.fast.ui.components.VkErrorView
import dev.meloda.fast.ui.theme.LocalHazeState import dev.meloda.fast.ui.theme.LocalHazeState
@@ -98,7 +98,7 @@ fun PhotoMaterialsScreen(
VkErrorView(baseError = baseError) VkErrorView(baseError = baseError)
} }
screenState.isLoading && screenState.materials.isEmpty() -> FullScreenLoader() screenState.isLoading && screenState.materials.isEmpty() -> FullScreenContainedLoader()
else -> { else -> {
PullToRefreshBox( PullToRefreshBox(
@@ -56,7 +56,7 @@ import dev.meloda.fast.model.BaseError
import dev.meloda.fast.ui.R import dev.meloda.fast.ui.R
import dev.meloda.fast.ui.basic.ContentAlpha import dev.meloda.fast.ui.basic.ContentAlpha
import dev.meloda.fast.ui.basic.LocalContentAlpha import dev.meloda.fast.ui.basic.LocalContentAlpha
import dev.meloda.fast.ui.components.FullScreenLoader import dev.meloda.fast.ui.components.FullScreenContainedLoader
import dev.meloda.fast.ui.components.NoItemsView import dev.meloda.fast.ui.components.NoItemsView
import dev.meloda.fast.ui.components.VkErrorView import dev.meloda.fast.ui.components.VkErrorView
import dev.meloda.fast.ui.theme.LocalHazeState import dev.meloda.fast.ui.theme.LocalHazeState
@@ -107,7 +107,7 @@ fun VideoMaterialsScreen(
VkErrorView(baseError = baseError) VkErrorView(baseError = baseError)
} }
screenState.isLoading && screenState.materials.isEmpty() -> FullScreenLoader() screenState.isLoading && screenState.materials.isEmpty() -> FullScreenContainedLoader()
else -> { else -> {
PullToRefreshBox( PullToRefreshBox(
@@ -44,7 +44,6 @@ import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.derivedStateOf import androidx.compose.runtime.derivedStateOf
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue 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.model.ConversationsScreenState
import dev.meloda.fast.conversations.navigation.ConversationsGraph import dev.meloda.fast.conversations.navigation.ConversationsGraph
import dev.meloda.fast.model.BaseError 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.NoItemsView
import dev.meloda.fast.ui.components.VkErrorView import dev.meloda.fast.ui.components.VkErrorView
import dev.meloda.fast.ui.model.api.ConversationOption 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 -> { else -> {
val pullToRefreshState = rememberPullToRefreshState() val pullToRefreshState = rememberPullToRefreshState()
@@ -51,7 +51,6 @@ import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip import androidx.compose.ui.draw.clip
import androidx.compose.ui.focus.onFocusChanged import androidx.compose.ui.focus.onFocusChanged
import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.LayoutDirection 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.CreateChatViewModel
import dev.meloda.fast.conversations.model.CreateChatScreenState import dev.meloda.fast.conversations.model.CreateChatScreenState
import dev.meloda.fast.model.BaseError import dev.meloda.fast.model.BaseError
import dev.meloda.fast.ui.components.ErrorView import dev.meloda.fast.ui.components.FullScreenContainedLoader
import dev.meloda.fast.ui.components.FullScreenLoader
import dev.meloda.fast.ui.components.IconButton import dev.meloda.fast.ui.components.IconButton
import dev.meloda.fast.ui.components.NoItemsView import dev.meloda.fast.ui.components.NoItemsView
import dev.meloda.fast.ui.components.VkErrorView import dev.meloda.fast.ui.components.VkErrorView
@@ -271,7 +269,7 @@ fun CreateChatScreen(
VkErrorView(baseError = baseError) VkErrorView(baseError = baseError)
} }
screenState.isLoading && screenState.friends.isEmpty() -> FullScreenLoader() screenState.isLoading && screenState.friends.isEmpty() -> FullScreenContainedLoader()
else -> { else -> {
val pullToRefreshState = rememberPullToRefreshState() val pullToRefreshState = rememberPullToRefreshState()
@@ -1,7 +1,6 @@
package dev.meloda.fast.friends.presentation package dev.meloda.fast.friends.presentation
import android.content.Context import android.content.Context
import androidx.appcompat.app.AppCompatActivity
import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.calculateEndPadding import androidx.compose.foundation.layout.calculateEndPadding
import androidx.compose.foundation.layout.calculateStartPadding import androidx.compose.foundation.layout.calculateStartPadding
@@ -28,11 +27,9 @@ import coil.imageLoader
import coil.request.ImageRequest import coil.request.ImageRequest
import dev.chrisbanes.haze.hazeSource import dev.chrisbanes.haze.hazeSource
import dev.meloda.fast.friends.FriendsViewModel 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.friends.navigation.Friends
import dev.meloda.fast.ui.R 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.NoItemsView
import dev.meloda.fast.ui.components.VkErrorView import dev.meloda.fast.ui.components.VkErrorView
import dev.meloda.fast.ui.theme.LocalHazeState 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 dev.meloda.fast.ui.util.ImmutableList
import kotlinx.coroutines.flow.collectLatest import kotlinx.coroutines.flow.collectLatest
import kotlinx.coroutines.flow.debounce import kotlinx.coroutines.flow.debounce
import org.koin.androidx.compose.koinViewModel
@OptIn(ExperimentalMaterial3Api::class) @OptIn(ExperimentalMaterial3Api::class)
@Composable @Composable
@@ -136,7 +132,7 @@ fun FriendsScreen(
} }
when { when {
screenState.isLoading && screenState.friends.isEmpty() -> FullScreenLoader() screenState.isLoading && screenState.friends.isEmpty() -> FullScreenContainedLoader()
else -> { else -> {
val pullToRefreshState = rememberPullToRefreshState() val pullToRefreshState = rememberPullToRefreshState()
@@ -3,6 +3,7 @@ package dev.meloda.fast.messageshistory
import android.content.ClipData import android.content.ClipData
import android.content.ClipboardManager import android.content.ClipboardManager
import android.content.Context import android.content.Context
import android.graphics.Bitmap
import android.os.Build import android.os.Build
import android.os.Bundle import android.os.Bundle
import android.util.Log 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.font.FontWeight
import androidx.compose.ui.text.input.TextFieldValue import androidx.compose.ui.text.input.TextFieldValue
import androidx.compose.ui.text.style.TextDecoration 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.SavedStateHandle
import androidx.lifecycle.ViewModel import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope import androidx.lifecycle.viewModelScope
import coil.imageLoader
import coil.request.ImageRequest
import com.conena.nanokt.collections.indexOfFirstOrNull import com.conena.nanokt.collections.indexOfFirstOrNull
import com.conena.nanokt.text.isEmptyOrBlank import com.conena.nanokt.text.isEmptyOrBlank
import com.conena.nanokt.text.isNotEmptyOrBlank 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.FormatDataType
import dev.meloda.fast.model.api.domain.VkAttachment import dev.meloda.fast.model.api.domain.VkAttachment
import dev.meloda.fast.model.api.domain.VkMessage import dev.meloda.fast.model.api.domain.VkMessage
import dev.meloda.fast.model.api.domain.VkPhotoDomain
import dev.meloda.fast.network.VkErrorCode import dev.meloda.fast.network.VkErrorCode
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.update import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import java.io.File
import java.io.FileOutputStream
import kotlin.math.abs import kotlin.math.abs
import kotlin.random.Random import kotlin.random.Random
import dev.meloda.fast.ui.R as UiR import dev.meloda.fast.ui.R as UiR
@@ -163,10 +172,6 @@ class MessagesHistoryViewModelImpl(
updatesParser.onMessageMarkedAsImportant(::handleMessageMarkedAsImportant) updatesParser.onMessageMarkedAsImportant(::handleMessageMarkedAsImportant)
updatesParser.onMessageMarkedAsSpam(::handleMessageMarkedAsSpam) updatesParser.onMessageMarkedAsSpam(::handleMessageMarkedAsSpam)
updatesParser.onMessageMarkedAsNotSpam(::handleMessageMarkedAsNotSpam) updatesParser.onMessageMarkedAsNotSpam(::handleMessageMarkedAsNotSpam)
userSettings.showTimeInActionMessages.listenValue(viewModelScope) {
syncUiMessages()
}
} }
override fun onNavigationConsumed() { override fun onNavigationConsumed() {
@@ -1131,13 +1136,54 @@ class MessagesHistoryViewModelImpl(
} }
private fun copyMessage(message: VkMessage) { private fun copyMessage(message: VkMessage) {
val contentToCopy = message.text.orEmpty().trim()
if (contentToCopy.isEmpty()) return
val clipboardManager = val clipboardManager =
applicationContext.getSystemService(Context.CLIPBOARD_SERVICE) as 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) { if (Build.VERSION.SDK_INT <= Build.VERSION_CODES.S) {
Toast.makeText(applicationContext, UiR.string.copied_to_clipboard, Toast.LENGTH_SHORT) Toast.makeText(applicationContext, UiR.string.copied_to_clipboard, Toast.LENGTH_SHORT)
@@ -1155,7 +1201,7 @@ class MessagesHistoryViewModelImpl(
showName = false, showName = false,
prevMessage = messages.getOrNull(index + 1), prevMessage = messages.getOrNull(index + 1),
nextMessage = messages.getOrNull(index - 1), nextMessage = messages.getOrNull(index - 1),
showTimeInActionMessages = userSettings.showTimeInActionMessages.value, showTimeInActionMessages = AppSettings.Experimental.showTimeInActionMessages,
conversation = screenState.value.conversation, conversation = screenState.value.conversation,
isSelected = selectedMessages.indexOfFirstOrNull { it.id == message.id } != null isSelected = selectedMessages.indexOfFirstOrNull { it.id == message.id } != null
) )
@@ -5,6 +5,7 @@ import androidx.navigation.NavController
import androidx.navigation.NavGraphBuilder import androidx.navigation.NavGraphBuilder
import androidx.navigation.compose.composable import androidx.navigation.compose.composable
import androidx.navigation.toRoute import androidx.navigation.toRoute
import dev.meloda.fast.common.model.UiImage
import dev.meloda.fast.messageshistory.model.MessagesHistoryArguments import dev.meloda.fast.messageshistory.model.MessagesHistoryArguments
import dev.meloda.fast.messageshistory.presentation.MessagesHistoryRoute import dev.meloda.fast.messageshistory.presentation.MessagesHistoryRoute
import dev.meloda.fast.model.BaseError import dev.meloda.fast.model.BaseError
@@ -27,13 +28,15 @@ data class MessagesHistory(val arguments: MessagesHistoryArguments) {
fun NavGraphBuilder.messagesHistoryScreen( fun NavGraphBuilder.messagesHistoryScreen(
onError: (BaseError) -> Unit, onError: (BaseError) -> Unit,
onBack: () -> 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) { composable<MessagesHistory>(typeMap = MessagesHistory.typeMap) {
MessagesHistoryRoute( MessagesHistoryRoute(
onError = onError, onError = onError,
onBack = onBack, 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.MaterialTheme
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.rememberUpdatedState
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.alpha import androidx.compose.ui.draw.alpha
@@ -38,6 +40,9 @@ fun IncomingMessageBubble(
onClick: (VkAttachment) -> Unit = {}, onClick: (VkAttachment) -> Unit = {},
onLongClick: (VkAttachment) -> Unit = {} onLongClick: (VkAttachment) -> Unit = {}
) { ) {
val currentOnClick by rememberUpdatedState(onClick)
val currentOnLongClick by rememberUpdatedState(onLongClick)
Row( Row(
modifier = modifier modifier = modifier
.fillMaxWidth() .fillMaxWidth()
@@ -98,8 +103,8 @@ fun IncomingMessageBubble(
isImportant = message.isImportant, isImportant = message.isImportant,
isSelected = message.isSelected, isSelected = message.isSelected,
attachments = message.attachments?.toImmutableList(), attachments = message.attachments?.toImmutableList(),
onClick = onClick, onClick = currentOnClick,
onLongClick = onLongClick onLongClick = currentOnLongClick
) )
} }
} }
@@ -18,6 +18,7 @@ import androidx.compose.runtime.derivedStateOf
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableIntStateOf import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberUpdatedState
import androidx.compose.runtime.setValue import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
@@ -50,6 +51,9 @@ fun MessageBubble(
onClick: (VkAttachment) -> Unit = {}, onClick: (VkAttachment) -> Unit = {},
onLongClick: (VkAttachment) -> Unit = {} onLongClick: (VkAttachment) -> Unit = {}
) { ) {
val currentOnClick by rememberUpdatedState(onClick)
val currentOnLongClick by rememberUpdatedState(onLongClick)
val theme = LocalThemeConfig.current val theme = LocalThemeConfig.current
val backgroundColor = if (!isOut) { val backgroundColor = if (!isOut) {
MaterialTheme.colorScheme.surfaceColorAtElevation(2.dp) MaterialTheme.colorScheme.surfaceColorAtElevation(2.dp)
@@ -173,8 +177,8 @@ fun MessageBubble(
Attachments( Attachments(
modifier = Modifier, modifier = Modifier,
attachments = attachments, attachments = attachments,
onClick = onClick, onClick = currentOnClick,
onLongClick = onLongClick onLongClick = currentOnLongClick
) )
val dateStatusBackground = if (theme.darkMode) Color.Black.copy(alpha = 0.5f) val dateStatusBackground = if (theme.darkMode) Color.Black.copy(alpha = 0.5f)
@@ -63,7 +63,9 @@ fun MessagesHistoryInputBar(
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
message: TextFieldValue, message: TextFieldValue,
hazeState: HazeState, hazeState: HazeState,
enableHaptic: Boolean,
showEmojiButton: Boolean, showEmojiButton: Boolean,
showAttachmentButton: Boolean,
actionMode: ActionMode, actionMode: ActionMode,
onMessageInputChanged: (TextFieldValue) -> Unit = {}, onMessageInputChanged: (TextFieldValue) -> Unit = {},
onBoldRequested: () -> Unit = {}, onBoldRequested: () -> Unit = {},
@@ -179,7 +181,7 @@ fun MessagesHistoryInputBar(
} }
TextField( TextField(
modifier = modifier modifier = Modifier
.weight(1f) .weight(1f)
.addTextContextMenuComponents { .addTextContextMenuComponents {
separator() separator()
@@ -236,46 +238,47 @@ fun MessagesHistoryInputBar(
maxLines = 1, maxLines = 1,
overflow = TextOverflow.Ellipsis overflow = TextOverflow.Ellipsis
) )
} },
) )
if (showAttachmentButton) {
val attachmentRotation = remember { Animatable(0f) }
val attachmentRotation = remember { Animatable(0f) } Column(verticalArrangement = Arrangement.Bottom) {
IconButton(
Column(verticalArrangement = Arrangement.Bottom) { onClick = {
IconButton( onAttachmentButtonClicked()
onClick = { if (enableHaptic) {
onAttachmentButtonClicked() view.performHapticFeedback(
if (AppSettings.General.enableHaptic) { HapticFeedbackConstantsCompat.REJECT
view.performHapticFeedback(
HapticFeedbackConstantsCompat.REJECT
)
}
scope.launch {
for (i in 20 downTo 0 step 4) {
attachmentRotation.animateTo(
targetValue = i.toFloat(),
animationSpec = tween(50)
) )
if (i > 0) { }
scope.launch {
for (i in 20 downTo 0 step 4) {
attachmentRotation.animateTo( attachmentRotation.animateTo(
targetValue = -i.toFloat(), targetValue = i.toFloat(),
animationSpec = tween(50) 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) } val micRotation = remember { Animatable(0f) }
@@ -4,20 +4,21 @@ import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.lifecycle.compose.collectAsStateWithLifecycle 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.MessagesHistoryViewModel
import dev.meloda.fast.messageshistory.MessagesHistoryViewModelImpl import dev.meloda.fast.messageshistory.MessagesHistoryViewModelImpl
import dev.meloda.fast.messageshistory.model.MessageNavigation import dev.meloda.fast.messageshistory.model.MessageNavigation
import dev.meloda.fast.model.BaseError import dev.meloda.fast.model.BaseError
import dev.meloda.fast.ui.util.ImmutableList.Companion.toImmutableList import dev.meloda.fast.ui.util.ImmutableList.Companion.toImmutableList
import org.koin.androidx.compose.koinViewModel import org.koin.androidx.compose.koinViewModel
import org.koin.compose.koinInject
@Composable @Composable
fun MessagesHistoryRoute( fun MessagesHistoryRoute(
onError: (BaseError) -> Unit, onError: (BaseError) -> Unit,
onBack: () -> Unit, onBack: () -> Unit,
onNavigateToChatMaterials: (peerId: Long, conversationMessageId: Long) -> Unit, onNavigateToChatMaterials: (peerId: Long, conversationMessageId: Long) -> Unit,
onNavigateToPhotoViewer: (images: List<String>, index: Int) -> Unit,
viewModel: MessagesHistoryViewModel = koinViewModel<MessagesHistoryViewModelImpl>() viewModel: MessagesHistoryViewModel = koinViewModel<MessagesHistoryViewModelImpl>()
) { ) {
val screenState by viewModel.screenState.collectAsStateWithLifecycle() val screenState by viewModel.screenState.collectAsStateWithLifecycle()
@@ -30,9 +31,6 @@ fun MessagesHistoryRoute(
val canPaginate by viewModel.canPaginate.collectAsStateWithLifecycle() val canPaginate by viewModel.canPaginate.collectAsStateWithLifecycle()
val scrollIndex by viewModel.isNeedToScrollToIndex.collectAsStateWithLifecycle() val scrollIndex by viewModel.isNeedToScrollToIndex.collectAsStateWithLifecycle()
val userSettings: UserSettings = koinInject()
val showEmojiButton by userSettings.showEmojiButton.collectAsStateWithLifecycle()
LaunchedEffect(navigationEvent) { LaunchedEffect(navigationEvent) {
val needToConsume = when (val navigation = navigationEvent) { val needToConsume = when (val navigation = navigationEvent) {
null -> false null -> false
@@ -55,7 +53,9 @@ fun MessagesHistoryRoute(
selectedMessages = selectedMessages.toImmutableList(), selectedMessages = selectedMessages.toImmutableList(),
baseError = baseError, baseError = baseError,
canPaginate = canPaginate, canPaginate = canPaginate,
showEmojiButton = showEmojiButton, showEmojiButton = AppSettings.General.showEmojiButton,
showAttachmentButton = AppSettings.General.showAttachmentButton,
enableHaptic = AppSettings.General.enableHaptic,
onBack = onBack, onBack = onBack,
onClose = viewModel::onCloseButtonClicked, onClose = viewModel::onCloseButtonClicked,
onScrolledToIndex = viewModel::onScrolledToIndex, onScrolledToIndex = viewModel::onScrolledToIndex,
@@ -69,6 +69,7 @@ fun MessagesHistoryRoute(
onEmojiButtonLongClicked = viewModel::onEmojiButtonLongClicked, onEmojiButtonLongClicked = viewModel::onEmojiButtonLongClicked,
onMessageClicked = viewModel::onMessageClicked, onMessageClicked = viewModel::onMessageClicked,
onMessageLongClicked = viewModel::onMessageLongClicked, onMessageLongClicked = viewModel::onMessageLongClicked,
onPhotoClicked = onNavigateToPhotoViewer,
onPinnedMessageClicked = viewModel::onPinnedMessageClicked, onPinnedMessageClicked = viewModel::onPinnedMessageClicked,
onUnpinMessageButtonClicked = viewModel::onUnpinMessageClicked, onUnpinMessageButtonClicked = viewModel::onUnpinMessageClicked,
onDeleteSelectedButtonClicked = viewModel::onDeleteSelectedMessagesClicked, onDeleteSelectedButtonClicked = viewModel::onDeleteSelectedMessagesClicked,
@@ -14,7 +14,6 @@ import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.statusBars import androidx.compose.foundation.layout.statusBars
import androidx.compose.foundation.lazy.rememberLazyListState import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Scaffold 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.messageshistory.util.indexOfMessageByCmId
import dev.meloda.fast.model.BaseError import dev.meloda.fast.model.BaseError
import dev.meloda.fast.model.api.domain.VkMessage 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.components.VkErrorView
import dev.meloda.fast.ui.theme.LocalThemeConfig import dev.meloda.fast.ui.theme.LocalThemeConfig
import dev.meloda.fast.ui.util.ImmutableList import dev.meloda.fast.ui.util.ImmutableList
@@ -68,6 +68,8 @@ fun MessagesHistoryScreen(
baseError: BaseError? = null, baseError: BaseError? = null,
canPaginate: Boolean = false, canPaginate: Boolean = false,
showEmojiButton: Boolean = false, showEmojiButton: Boolean = false,
showAttachmentButton: Boolean = false,
enableHaptic: Boolean = false,
onBack: () -> Unit = {}, onBack: () -> Unit = {},
onClose: () -> Unit = {}, onClose: () -> Unit = {},
onScrolledToIndex: () -> Unit = {}, onScrolledToIndex: () -> Unit = {},
@@ -81,6 +83,7 @@ fun MessagesHistoryScreen(
onEmojiButtonLongClicked: () -> Unit = {}, onEmojiButtonLongClicked: () -> Unit = {},
onMessageClicked: (Long) -> Unit = {}, onMessageClicked: (Long) -> Unit = {},
onMessageLongClicked: (Long) -> Unit = {}, onMessageLongClicked: (Long) -> Unit = {},
onPhotoClicked: (images: List<String>, index: Int) -> Unit = { _, _ -> },
onPinnedMessageClicked: (Long) -> Unit = {}, onPinnedMessageClicked: (Long) -> Unit = {},
onUnpinMessageButtonClicked: () -> Unit = {}, onUnpinMessageButtonClicked: () -> Unit = {},
onDeleteSelectedButtonClicked: () -> Unit = {}, onDeleteSelectedButtonClicked: () -> Unit = {},
@@ -231,7 +234,8 @@ fun MessagesHistoryScreen(
} }
currentOnMessageClicked.invoke(id) currentOnMessageClicked.invoke(id)
}, },
onMessageLongClicked = onMessageLongClicked onMessageLongClicked = onMessageLongClicked,
onPhotoClicked = onPhotoClicked
) )
MessagesHistoryInputBar( MessagesHistoryInputBar(
@@ -244,7 +248,9 @@ fun MessagesHistoryScreen(
onLinkRequested = onLinkRequested, onLinkRequested = onLinkRequested,
onRegularRequested = onRegularRequested, onRegularRequested = onRegularRequested,
hazeState = hazeState, hazeState = hazeState,
enableHaptic = enableHaptic,
showEmojiButton = showEmojiButton, showEmojiButton = showEmojiButton,
showAttachmentButton = showAttachmentButton,
actionMode = screenState.actionMode, actionMode = screenState.actionMode,
onSetMessageBarHeight = { messageBarHeight = it }, onSetMessageBarHeight = { messageBarHeight = it },
onEmojiButtonLongClicked = onEmojiButtonLongClicked, onEmojiButtonLongClicked = onEmojiButtonLongClicked,
@@ -254,7 +260,7 @@ fun MessagesHistoryScreen(
when { when {
screenState.isLoading && messages.values.isEmpty() -> { screenState.isLoading && messages.values.isEmpty() -> {
CircularProgressIndicator(modifier = Modifier.align(Alignment.Center)) Loader(modifier = Modifier.align(Alignment.Center))
} }
baseError != null -> { baseError != null -> {
@@ -23,7 +23,7 @@ import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface import androidx.compose.material3.Surface
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember import androidx.compose.runtime.rememberUpdatedState
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Color
@@ -56,25 +56,36 @@ fun MessagesList(
messageBarHeight: Dp, messageBarHeight: Dp,
onRequestScrollToCmId: (cmId: Long) -> Unit = {}, onRequestScrollToCmId: (cmId: Long) -> Unit = {},
onMessageClicked: (Long) -> Unit = {}, onMessageClicked: (Long) -> Unit = {},
onMessageLongClicked: (Long) -> Unit = {} onMessageLongClicked: (Long) -> Unit = {},
onPhotoClicked: (images: List<String>, index: Int) -> Unit = { _, _ -> }
) { ) {
val context = LocalContext.current val context = LocalContext.current
val theme = LocalThemeConfig.current val theme = LocalThemeConfig.current
val view = LocalView.current val view = LocalView.current
val onAttachmentClick = remember { val onAttachmentClick by rememberUpdatedState(
{ message: UiItem.Message, attachment: VkAttachment -> { message: UiItem.Message, attachment: VkAttachment ->
if (isSelectedAtLeastOne) { if (isSelectedAtLeastOne) {
onMessageClicked(message.id) onMessageClicked(message.id)
} else { } else {
when (attachment) { when (attachment) {
is VkPhotoDomain -> { is VkPhotoDomain -> {
val maxSize = attachment.getMaxSize() val photos = message.attachments
maxSize?.let { .orEmpty()
context.startActivity( .filterIsInstance<VkPhotoDomain>()
Intent(Intent.ACTION_VIEW, maxSize.url.toUri()) .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 -> { is VkFileDomain -> {
@@ -91,9 +102,9 @@ fun MessagesList(
} }
} }
} }
} )
val onAttachmentLongClick = remember { val onAttachmentLongClick by rememberUpdatedState(
{ message: UiItem.Message, attachment: VkAttachment -> { message: UiItem.Message, attachment: VkAttachment ->
if (isSelectedAtLeastOne) { if (isSelectedAtLeastOne) {
onMessageLongClicked(message.id) onMessageLongClicked(message.id)
@@ -107,7 +118,7 @@ fun MessagesList(
} }
} }
} }
} )
LazyColumn( LazyColumn(
modifier = modifier modifier = modifier
@@ -200,6 +211,7 @@ fun MessagesList(
), ),
message = item, message = item,
onClick = { attachment -> onClick = { attachment ->
onAttachmentClick(item, attachment) onAttachmentClick(item, attachment)
}, },
onLongClick = { attachment -> onLongClick = { attachment ->
@@ -1,21 +1,43 @@
package dev.meloda.fast.photoviewer 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.SavedStateHandle
import androidx.lifecycle.ViewModel 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.extensions.setValue
import dev.meloda.fast.common.model.UiImage import dev.meloda.fast.common.model.UiImage
import dev.meloda.fast.photoviewer.model.PhotoViewScreenState import dev.meloda.fast.photoviewer.model.PhotoViewScreenState
import dev.meloda.fast.photoviewer.navigation.PhotoView import dev.meloda.fast.photoviewer.navigation.PhotoView
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow 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.net.URLDecoder
import java.util.UUID
interface PhotoViewViewModel { interface PhotoViewViewModel {
val screenState: StateFlow<PhotoViewScreenState> val screenState: StateFlow<PhotoViewScreenState>
fun onPageChanged(newPage: Int)
fun onCopyLinkClicked()
fun onCopyClicked()
} }
class PhotoViewViewModelImpl( class PhotoViewViewModelImpl(
savedStateHandle: SavedStateHandle savedStateHandle: SavedStateHandle,
private val applicationContext: Context
) : PhotoViewViewModel, ViewModel() { ) : PhotoViewViewModel, ViewModel() {
override val screenState = MutableStateFlow(PhotoViewScreenState.EMPTY) override val screenState = MutableStateFlow(PhotoViewScreenState.EMPTY)
@@ -25,10 +47,73 @@ class PhotoViewViewModelImpl(
screenState.setValue { old -> screenState.setValue { old ->
old.copy( old.copy(
images = arguments.images images = arguments.imageUrls
.map { URLDecoder.decode(it, "utf-8") } .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 @Parcelize
@Serializable @Serializable
data class PhotoViewArguments( data class PhotoViewArguments(
val images: List<String> val imageUrls: List<String>,
val selectedIndex: Int?
) : Parcelable ) : Parcelable
@@ -5,12 +5,14 @@ import dev.meloda.fast.common.model.UiImage
@Immutable @Immutable
data class PhotoViewScreenState( data class PhotoViewScreenState(
val images: List<UiImage> val images: List<UiImage>,
val selectedPage: Int
) { ) {
companion object { companion object {
val EMPTY: PhotoViewScreenState = PhotoViewScreenState( 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( this.navigate(
PhotoView( PhotoView(
arguments = PhotoViewArguments( 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.gestures.rememberDraggableState
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.defaultMinSize
import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.pager.HorizontalPager 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.foundation.pager.rememberPagerState
import androidx.compose.material.icons.Icons import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.rounded.ArrowBack 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.ExperimentalMaterial3Api
import androidx.compose.material3.Icon import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Scaffold import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text
import androidx.compose.material3.TopAppBar import androidx.compose.material3.TopAppBar
import androidx.compose.material3.TopAppBarDefaults import androidx.compose.material3.TopAppBarDefaults
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.derivedStateOf import androidx.compose.runtime.derivedStateOf
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableFloatStateOf import androidx.compose.runtime.mutableFloatStateOf
import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberUpdatedState
import androidx.compose.runtime.setValue import androidx.compose.runtime.setValue
import androidx.compose.runtime.snapshotFlow
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.graphicsLayer import androidx.compose.ui.graphics.graphicsLayer
import androidx.compose.ui.graphics.painter.ColorPainter import androidx.compose.ui.graphics.painter.ColorPainter
import androidx.compose.ui.graphics.painter.Painter 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.tooling.preview.Preview
import androidx.compose.ui.unit.DpOffset
import androidx.compose.ui.unit.dp
import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.lifecycle.compose.collectAsStateWithLifecycle
import coil.compose.AsyncImage import coil.compose.AsyncImage
import com.conena.nanokt.android.content.pxToDp import com.conena.nanokt.android.content.pxToDp
@@ -56,16 +66,30 @@ fun PhotoViewRoute(
PhotoViewScreen( PhotoViewScreen(
screenState = screenState, screenState = screenState,
onBack = onBack onBack = onBack,
onPageChanged = viewModel::onPageChanged,
onCopyLinkClicked = viewModel::onCopyLinkClicked,
onCopyClicked = viewModel::onCopyClicked
) )
} }
@Composable @Composable
fun PhotoViewScreen( fun PhotoViewScreen(
screenState: PhotoViewScreenState = PhotoViewScreenState.EMPTY, 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) } var offsetY by remember { mutableFloatStateOf(0f) }
@@ -81,7 +105,13 @@ fun PhotoViewScreen(
Scaffold( Scaffold(
modifier = Modifier.graphicsLayer(alpha = calculatedAlpha), modifier = Modifier.graphicsLayer(alpha = calculatedAlpha),
topBar = { TopBar(onBack = onBack) }, topBar = {
TopBar(
onBack = onBack,
onCopyClicked = onCopyClicked,
onCopyLinkClicked = onCopyLinkClicked,
)
},
containerColor = MaterialTheme.colorScheme.background.copy( containerColor = MaterialTheme.colorScheme.background.copy(
alpha = calculatedAlpha alpha = calculatedAlpha
) )
@@ -103,14 +133,18 @@ fun PhotoViewScreen(
@Composable @Composable
fun TopBar( fun TopBar(
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
onBack: () -> Unit onBack: () -> Unit,
onCopyClicked: () -> Unit,
onCopyLinkClicked: () -> Unit
) { ) {
val context = LocalContext.current
var dropdownMenuShown by remember { var dropdownMenuShown by remember {
mutableStateOf(false) mutableStateOf(false)
} }
val hideDropDownMenu by rememberUpdatedState(
{ dropdownMenuShown = false }
)
TopAppBar( TopAppBar(
modifier = modifier, modifier = modifier,
title = {}, title = {},
@@ -123,29 +157,40 @@ fun TopBar(
} }
}, },
actions = { actions = {
// IconButton.kt( IconButton(
// onClick = { dropdownMenuShown = true } onClick = { dropdownMenuShown = true }
// ) { ) {
// Icon( Icon(
// imageVector = Icons.Rounded.MoreVert, imageVector = Icons.Rounded.MoreVert,
// contentDescription = "Options" contentDescription = "Options"
// ) )
// } }
// DropdownMenu( DropdownMenu(
// modifier = Modifier.defaultMinSize(minWidth = 140.dp), modifier = Modifier.defaultMinSize(minWidth = 140.dp),
// expanded = dropdownMenuShown, expanded = dropdownMenuShown,
// onDismissRequest = { dropdownMenuShown = false }, onDismissRequest = { dropdownMenuShown = false },
// offset = DpOffset(x = (10).dp, y = (-60).dp) offset = DpOffset(x = (10).dp, y = (-60).dp)
// ) { ) {
// DropdownMenuItem( DropdownMenuItem(
// onClick = { onClick = {
// Toast.makeText(context, "Save clicked", Toast.LENGTH_SHORT).show() hideDropDownMenu()
// dropdownMenuShown = false onCopyLinkClicked()
// }, },
// text = { Text(text = "Save") }, 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) colors = TopAppBarDefaults.topAppBarColors(containerColor = Color.Transparent)
) )
@@ -230,7 +275,8 @@ private fun PhotoViewScreenPreview() {
screenState = PhotoViewScreenState( screenState = PhotoViewScreenState(
images = List(200) { images = List(200) {
UiImage.Resource(UiR.drawable.test_captcha) UiImage.Resource(UiR.drawable.test_captcha)
} },
selectedPage = 0
) )
) )
} }
@@ -201,24 +201,12 @@ class SettingsViewModelImpl(
userSettings.onAppLanguageChanged(newLanguage) 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 -> { SettingsKeys.KEY_ACTIVITY_SEND_ONLINE_STATUS -> {
val isUsing = newValue as? Boolean val isUsing = newValue as? Boolean
?: SettingsKeys.DEFAULT_VALUE_KEY_ACTIVITY_SEND_ONLINE_STATUS ?: SettingsKeys.DEFAULT_VALUE_KEY_ACTIVITY_SEND_ONLINE_STATUS
userSettings.onSendOnlineStatusChanged(isUsing) 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 -> { SettingsKeys.KEY_LONG_POLL_IN_BACKGROUND -> {
val inBackground = newValue as? Boolean val inBackground = newValue as? Boolean
?: SettingsKeys.DEFAULT_LONG_POLL_IN_BACKGROUND ?: SettingsKeys.DEFAULT_LONG_POLL_IN_BACKGROUND
@@ -240,17 +228,6 @@ class SettingsViewModelImpl(
userSettings.onUseBlurChanged(isUsing) 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 -> { SettingsKeys.KEY_USE_SYSTEM_FONT -> {
val use = newValue as? Boolean ?: SettingsKeys.DEFAULT_USE_SYSTEM_FONT val use = newValue as? Boolean ?: SettingsKeys.DEFAULT_USE_SYSTEM_FONT
userSettings.onUseSystemFontChanged(use) userSettings.onUseSystemFontChanged(use)
@@ -302,6 +279,12 @@ class SettingsViewModelImpl(
text = UiText.Resource(UiR.string.settings_general_show_emoji_button_summary), text = UiText.Resource(UiR.string.settings_general_show_emoji_button_summary),
defaultValue = SettingsKeys.DEFAULT_VALUE_KEY_SHOW_EMOJI_BUTTON 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( val generalEnableHaptic = SettingsItem.Switch(
key = SettingsKeys.KEY_ENABLE_HAPTIC, key = SettingsKeys.KEY_ENABLE_HAPTIC,
defaultValue = SettingsKeys.DEFAULT_ENABLE_HAPTIC, defaultValue = SettingsKeys.DEFAULT_ENABLE_HAPTIC,
@@ -476,6 +459,7 @@ class SettingsViewModelImpl(
generalTitle, generalTitle,
generalUseContactNames, generalUseContactNames,
generalShowEmojiButton, generalShowEmojiButton,
generalShowAttachmentButton,
generalEnableHaptic generalEnableHaptic
) )
val appearanceList = listOf( val appearanceList = listOf(