From d9cc00dcade234eddfc47a4c603e1a6b107015df Mon Sep 17 00:00:00 2001 From: Artur Skubei <41114720+gdlbo@users.noreply.github.com> Date: Tue, 14 Apr 2026 22:46:41 +0300 Subject: [PATCH 1/5] implement poll creation and file attachment support in chat input - introduce `PollComposerSheet` and `PollDraft` to support creating and sending polls with options for quiz mode, anonymity, multiple answers, and scheduling - add document attachment support by implementing `copyUriToTempDocumentPath` and updating `ChatInputBar` to handle file selection - update `ChatInputBar` and `InputPreviewSection` to display previews for pending document attachments with file size and name metadata - extend `MessageRepository` and `MessageRemoteDataSource` with `sendPoll` and `sendDocument` functionality using TDLib - refine `GalleryScreen` and `AttachBotsSection` to include quick action buttons for "File" and "Create poll" - adjust `SettingsTextField` and `PollSwitchRow` to improve UI consistency in the poll composer and chat creation screens - enhance `InputBarSendButton` logic to correctly handle the send/record state when files or polls are pending - add necessary string resources for poll configuration and file attachment labels --- .../remote/MessageRemoteDataSource.kt | 8 + .../remote/TdMessageRemoteDataSource.kt | 58 ++ .../data/repository/MessageRepositoryImpl.kt | 19 +- .../org/monogram/domain/models/PollDraft.kt | 18 + .../domain/repository/MessageRepository.kt | 9 + .../chatList/components/ChatCreationCommon.kt | 26 +- .../chats/currentChat/ChatComponent.kt | 13 + .../features/chats/currentChat/ChatContent.kt | 38 +- .../features/chats/currentChat/ChatStore.kt | 13 + .../chats/currentChat/ChatStoreFactory.kt | 14 + .../chats/currentChat/DefaultChatComponent.kt | 20 + .../currentChat/components/ChatInputBar.kt | 90 ++- .../inputbar/ChatInputBarComposerSection.kt | 9 + .../inputbar/ChatInputBarHelpers.kt | 42 +- .../inputbar/FullScreenEditorSheet.kt | 99 ++- .../inputbar/InputBarLeadingIcons.kt | 12 +- .../components/inputbar/InputBarSendButton.kt | 29 +- .../inputbar/InputPreviewSection.kt | 126 +++- .../components/inputbar/InputTextField.kt | 3 +- .../inputbar/InputTextFieldContainer.kt | 2 + .../chats/currentChat/impl/MessageActions.kt | 61 +- .../features/gallery/GalleryScreen.kt | 41 +- .../gallery/components/AttachBotsSection.kt | 103 ++- .../gallery/components/PollComposerSheet.kt | 680 ++++++++++++++++++ presentation/src/main/res/values/string.xml | 19 + 25 files changed, 1492 insertions(+), 60 deletions(-) create mode 100644 domain/src/main/java/org/monogram/domain/models/PollDraft.kt create mode 100644 presentation/src/main/java/org/monogram/presentation/features/gallery/components/PollComposerSheet.kt diff --git a/data/src/main/java/org/monogram/data/datasource/remote/MessageRemoteDataSource.kt b/data/src/main/java/org/monogram/data/datasource/remote/MessageRemoteDataSource.kt index 896ecf28..0ecba453 100644 --- a/data/src/main/java/org/monogram/data/datasource/remote/MessageRemoteDataSource.kt +++ b/data/src/main/java/org/monogram/data/datasource/remote/MessageRemoteDataSource.kt @@ -12,6 +12,7 @@ import org.monogram.domain.models.MessageModel import org.monogram.domain.models.MessageSendOptions import org.monogram.domain.models.MessageUploadProgressEvent import org.monogram.domain.models.MessageViewerModel +import org.monogram.domain.models.PollDraft import org.monogram.domain.models.UserModel import org.monogram.domain.models.webapp.ThemeParams import org.monogram.domain.models.webapp.WebAppInfoModel @@ -82,6 +83,13 @@ interface MessageRemoteDataSource { threadId: Long?, sendOptions: MessageSendOptions ): TdApi.Message? + suspend fun sendPoll( + chatId: Long, + poll: PollDraft, + replyToMsgId: Long?, + threadId: Long?, + sendOptions: MessageSendOptions + ): TdApi.Message? suspend fun sendSticker(chatId: Long, stickerPath: String, replyToMsgId: Long?, threadId: Long?): TdApi.Message? suspend fun sendGif( diff --git a/data/src/main/java/org/monogram/data/datasource/remote/TdMessageRemoteDataSource.kt b/data/src/main/java/org/monogram/data/datasource/remote/TdMessageRemoteDataSource.kt index 6a2e37b9..99a29e02 100644 --- a/data/src/main/java/org/monogram/data/datasource/remote/TdMessageRemoteDataSource.kt +++ b/data/src/main/java/org/monogram/data/datasource/remote/TdMessageRemoteDataSource.kt @@ -36,6 +36,7 @@ import org.monogram.domain.models.MessageModel import org.monogram.domain.models.MessageSendOptions import org.monogram.domain.models.MessageUploadProgressEvent import org.monogram.domain.models.MessageViewerModel +import org.monogram.domain.models.PollDraft import org.monogram.domain.models.UserModel import org.monogram.domain.models.webapp.ThemeParams import org.monogram.domain.models.webapp.WebAppInfoModel @@ -616,6 +617,63 @@ class TdMessageRemoteDataSource( return response } + override suspend fun sendPoll( + chatId: Long, + poll: PollDraft, + replyToMsgId: Long?, + threadId: Long?, + sendOptions: MessageSendOptions + ): TdApi.Message? { + val formattedQuestion = TdApi.FormattedText(poll.question, emptyArray()) + val pollOptions = poll.options.map { option -> + TdApi.InputPollOption(TdApi.FormattedText(option, emptyArray())) + }.toTypedArray() + val type = if (poll.isQuiz) { + val correctOptionIds = poll.correctOptionIds + .map { it.coerceAtLeast(0) } + .distinct() + .toIntArray() + TdApi.InputPollTypeQuiz( + if (correctOptionIds.isNotEmpty()) correctOptionIds else intArrayOf(0), + TdApi.FormattedText(poll.explanation.orEmpty(), emptyArray()) + ) + } else { + TdApi.InputPollTypeRegular() + } + val content = TdApi.InputMessagePoll().apply { + this.question = formattedQuestion + this.options = pollOptions + this.description = poll.description + ?.takeIf { it.isNotBlank() } + ?.let { TdApi.FormattedText(it, emptyArray()) } + this.isAnonymous = poll.isAnonymous + this.allowsMultipleAnswers = poll.allowsMultipleAnswers + this.allowsRevoting = poll.allowsRevoting + this.shuffleOptions = poll.shuffleOptions + this.hideResultsUntilCloses = poll.hideResultsUntilCloses + this.type = type + this.openPeriod = poll.openPeriod.coerceAtLeast(0) + this.closeDate = poll.closeDate.coerceAtLeast(0) + this.isClosed = poll.isClosed + } + val replyTo = + if (replyToMsgId != null && replyToMsgId != 0L) TdApi.InputMessageReplyToMessage( + replyToMsgId, + null, + 0, + "" + ) else null + val topicId = resolveTopicId(chatId, threadId) + val req = TdApi.SendMessage().apply { + this.chatId = chatId + this.topicId = topicId + this.replyTo = replyTo + this.inputMessageContent = content + this.options = sendOptions.toTdMessageSendOptions() + } + return safeExecute(req) + } + override suspend fun sendSticker(chatId: Long, stickerPath: String, replyToMsgId: Long?, threadId: Long?): TdApi.Message? { val content = TdApi.InputMessageSticker().apply { this.sticker = TdApi.InputFileLocal(stickerPath) diff --git a/data/src/main/java/org/monogram/data/repository/MessageRepositoryImpl.kt b/data/src/main/java/org/monogram/data/repository/MessageRepositoryImpl.kt index 870949ea..df90bcc1 100644 --- a/data/src/main/java/org/monogram/data/repository/MessageRepositoryImpl.kt +++ b/data/src/main/java/org/monogram/data/repository/MessageRepositoryImpl.kt @@ -36,13 +36,14 @@ import org.monogram.domain.models.ChatEventModel import org.monogram.domain.models.ChatPermissionsModel import org.monogram.domain.models.FileModel import org.monogram.domain.models.InlineQueryResultModel +import org.monogram.domain.models.MessageDownloadEvent import org.monogram.domain.models.MessageEntity import org.monogram.domain.models.MessageEntityType -import org.monogram.domain.models.MessageDownloadEvent import org.monogram.domain.models.MessageModel import org.monogram.domain.models.MessageSendOptions import org.monogram.domain.models.MessageSenderModel import org.monogram.domain.models.MessageViewerModel +import org.monogram.domain.models.PollDraft import org.monogram.domain.models.UserModel import org.monogram.domain.models.webapp.InstantViewModel import org.monogram.domain.models.webapp.InvoiceModel @@ -340,6 +341,22 @@ class MessageRepositoryImpl( ) } + override suspend fun sendPoll( + chatId: Long, + poll: PollDraft, + replyToMsgId: Long?, + threadId: Long?, + sendOptions: MessageSendOptions + ) { + messageRemoteDataSource.sendPoll( + chatId = chatId, + poll = poll, + replyToMsgId = replyToMsgId, + threadId = threadId, + sendOptions = sendOptions + ) + } + override suspend fun sendGif( chatId: Long, gifId: String, diff --git a/domain/src/main/java/org/monogram/domain/models/PollDraft.kt b/domain/src/main/java/org/monogram/domain/models/PollDraft.kt new file mode 100644 index 00000000..7c161ce6 --- /dev/null +++ b/domain/src/main/java/org/monogram/domain/models/PollDraft.kt @@ -0,0 +1,18 @@ +package org.monogram.domain.models + +data class PollDraft( + val question: String, + val options: List, + val description: String? = null, + val isAnonymous: Boolean = true, + val allowsMultipleAnswers: Boolean = false, + val allowsRevoting: Boolean = true, + val shuffleOptions: Boolean = false, + val hideResultsUntilCloses: Boolean = false, + val openPeriod: Int = 0, + val closeDate: Int = 0, + val isClosed: Boolean = false, + val isQuiz: Boolean = false, + val correctOptionIds: List = emptyList(), + val explanation: String? = null +) diff --git a/domain/src/main/java/org/monogram/domain/repository/MessageRepository.kt b/domain/src/main/java/org/monogram/domain/repository/MessageRepository.kt index dae5b713..46f61c44 100644 --- a/domain/src/main/java/org/monogram/domain/repository/MessageRepository.kt +++ b/domain/src/main/java/org/monogram/domain/repository/MessageRepository.kt @@ -9,6 +9,7 @@ import org.monogram.domain.models.MessageModel import org.monogram.domain.models.MessageSendOptions import org.monogram.domain.models.MessageUploadProgressEvent import org.monogram.domain.models.MessageViewerModel +import org.monogram.domain.models.PollDraft import org.monogram.domain.models.UserModel import org.monogram.domain.models.webapp.InstantViewModel @@ -128,6 +129,14 @@ interface MessageRepository : sendOptions: MessageSendOptions = MessageSendOptions() ) + suspend fun sendPoll( + chatId: Long, + poll: PollDraft, + replyToMsgId: Long? = null, + threadId: Long? = null, + sendOptions: MessageSendOptions = MessageSendOptions() + ) + suspend fun sendGif( chatId: Long, gifId: String, diff --git a/presentation/src/main/java/org/monogram/presentation/features/chats/chatList/components/ChatCreationCommon.kt b/presentation/src/main/java/org/monogram/presentation/features/chats/chatList/components/ChatCreationCommon.kt index 3dee2605..48161788 100644 --- a/presentation/src/main/java/org/monogram/presentation/features/chats/chatList/components/ChatCreationCommon.kt +++ b/presentation/src/main/java/org/monogram/presentation/features/chats/chatList/components/ChatCreationCommon.kt @@ -1,14 +1,30 @@ package org.monogram.presentation.features.chats.chatList.components import androidx.compose.foundation.clickable -import androidx.compose.foundation.layout.* +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.heightIn +import androidx.compose.foundation.layout.padding import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.text.KeyboardActions import androidx.compose.foundation.text.KeyboardOptions import androidx.compose.material.icons.Icons import androidx.compose.material.icons.rounded.Check -import androidx.compose.material3.* +import androidx.compose.material3.BottomSheetDefaults +import androidx.compose.material3.Button +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.ModalBottomSheet +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.material3.TextField +import androidx.compose.material3.TextFieldDefaults +import androidx.compose.material3.rememberModalBottomSheetState import androidx.compose.runtime.Composable import androidx.compose.runtime.remember import androidx.compose.ui.Alignment @@ -17,6 +33,7 @@ import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.vector.ImageVector import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import org.monogram.presentation.R @@ -47,6 +64,7 @@ fun SettingsTextField( singleLine: Boolean = false, minLines: Int = 1, maxLines: Int = Int.MAX_VALUE, + itemSpacing: Dp = 2.dp, keyboardOptions: KeyboardOptions = KeyboardOptions.Default, keyboardActions: KeyboardActions = KeyboardActions.Default, trailingIcon: @Composable (() -> Unit)? = null @@ -109,8 +127,8 @@ fun SettingsTextField( ) ) } - if (position != ItemPosition.BOTTOM && position != ItemPosition.STANDALONE) { - Spacer(Modifier.height(2.dp)) + if (position != ItemPosition.BOTTOM && position != ItemPosition.STANDALONE && itemSpacing > 0.dp) { + Spacer(Modifier.height(itemSpacing)) } } diff --git a/presentation/src/main/java/org/monogram/presentation/features/chats/currentChat/ChatComponent.kt b/presentation/src/main/java/org/monogram/presentation/features/chats/currentChat/ChatComponent.kt index cec5e0f9..cd34e352 100644 --- a/presentation/src/main/java/org/monogram/presentation/features/chats/currentChat/ChatComponent.kt +++ b/presentation/src/main/java/org/monogram/presentation/features/chats/currentChat/ChatComponent.kt @@ -15,6 +15,7 @@ import org.monogram.domain.models.MessageEntity import org.monogram.domain.models.MessageModel import org.monogram.domain.models.MessageSendOptions import org.monogram.domain.models.MessageViewerModel +import org.monogram.domain.models.PollDraft import org.monogram.domain.models.StickerSetModel import org.monogram.domain.models.TopicModel import org.monogram.domain.models.UserModel @@ -56,6 +57,18 @@ interface ChatComponent { ) fun onSendGif(gif: GifModel) + fun onSendDocument( + documentPath: String, + caption: String = "", + captionEntities: List = emptyList(), + sendOptions: MessageSendOptions = MessageSendOptions() + ) + + fun onSendPoll( + poll: PollDraft, + sendOptions: MessageSendOptions = MessageSendOptions() + ) + fun onSendGifFile( path: String, caption: String = "", diff --git a/presentation/src/main/java/org/monogram/presentation/features/chats/currentChat/ChatContent.kt b/presentation/src/main/java/org/monogram/presentation/features/chats/currentChat/ChatContent.kt index c427a3ac..2978b9a2 100644 --- a/presentation/src/main/java/org/monogram/presentation/features/chats/currentChat/ChatContent.kt +++ b/presentation/src/main/java/org/monogram/presentation/features/chats/currentChat/ChatContent.kt @@ -193,6 +193,7 @@ fun ChatContent( var contentRect by remember { mutableStateOf(Rect.Zero) } var pendingMediaPaths by rememberSaveable { mutableStateOf>(emptyList()) } + var pendingDocumentPaths by rememberSaveable { mutableStateOf>(emptyList()) } var editingPhotoPath by rememberSaveable { mutableStateOf(null) } var editingVideoPath by rememberSaveable { mutableStateOf(null) } var pendingBlockUserId by rememberSaveable { mutableStateOf(null) } @@ -574,6 +575,7 @@ fun ChatContent( else albumPaths.add(file.absolutePath) } if (albumPaths.isNotEmpty()) pendingMediaPaths = (pendingMediaPaths + albumPaths).distinct() + if (albumPaths.isNotEmpty()) pendingDocumentPaths = emptyList() } @@ -797,12 +799,14 @@ fun ChatContent( }, bottomBar = { if (showInputBar) { - val inputBarState = remember(state, pendingMediaPaths) { + val inputBarState = + remember(state, pendingMediaPaths, pendingDocumentPaths) { ChatInputBarState( replyMessage = state.replyMessage, editingMessage = state.editingMessage, draftText = state.draftText, pendingMediaPaths = pendingMediaPaths, + pendingDocumentPaths = pendingDocumentPaths, isClosed = state.topics.find { it.id.toLong() == state.currentTopicId }?.isClosed ?: false, permissions = state.permissions, @@ -828,7 +832,8 @@ fun ChatContent( ) } - val inputBarActions = remember(component, pendingMediaPaths) { + val inputBarActions = + remember(component, pendingMediaPaths, pendingDocumentPaths) { ChatInputBarActions( onSend = { text, entities, options -> component.onSendMessage( @@ -877,8 +882,32 @@ fun ChatContent( else component.onSendPhoto(it, caption, captionEntities, options) } pendingMediaPaths = emptyList() + pendingDocumentPaths = emptyList() + }, + onSendDocuments = { paths, caption, captionEntities, options -> + paths.forEachIndexed { index, path -> + component.onSendDocument( + path, + caption = if (index == 0) caption else "", + captionEntities = if (index == 0) captionEntities else emptyList(), + sendOptions = options + ) + } + pendingDocumentPaths = emptyList() + pendingMediaPaths = emptyList() + }, + onMediaOrderChange = { + pendingMediaPaths = it + if (it.isNotEmpty()) { + pendingDocumentPaths = emptyList() + } + }, + onDocumentOrderChange = { + pendingDocumentPaths = it + if (it.isNotEmpty()) { + pendingMediaPaths = emptyList() + } }, - onMediaOrderChange = { pendingMediaPaths = it }, onMediaClick = { path -> if (path.endsWith(".mp4")) { editingVideoPath = path @@ -923,6 +952,9 @@ fun ChatContent( onAttachBotClick = { bot -> component.onOpenAttachBot(bot.botUserId, bot.name) }, + onSendPoll = { poll -> + component.onSendPoll(poll) + }, onRefreshScheduledMessages = { component.onRefreshScheduledMessages() }, onEditScheduledMessage = { message -> component.onEditMessage(message) }, onDeleteScheduledMessage = { message -> component.onDeleteMessage(message) }, diff --git a/presentation/src/main/java/org/monogram/presentation/features/chats/currentChat/ChatStore.kt b/presentation/src/main/java/org/monogram/presentation/features/chats/currentChat/ChatStore.kt index 91760061..2e48a83b 100644 --- a/presentation/src/main/java/org/monogram/presentation/features/chats/currentChat/ChatStore.kt +++ b/presentation/src/main/java/org/monogram/presentation/features/chats/currentChat/ChatStore.kt @@ -10,6 +10,7 @@ import org.monogram.domain.models.KeyboardButtonModel import org.monogram.domain.models.MessageEntity import org.monogram.domain.models.MessageModel import org.monogram.domain.models.MessageSendOptions +import org.monogram.domain.models.PollDraft import java.io.File interface ChatStore : Store { @@ -38,6 +39,18 @@ interface ChatStore : Store = emptyList(), + val sendOptions: MessageSendOptions = MessageSendOptions() + ) : Intent() + + data class SendPoll( + val poll: PollDraft, + val sendOptions: MessageSendOptions = MessageSendOptions() + ) : Intent() + data class SendGifFile( val path: String, val caption: String = "", diff --git a/presentation/src/main/java/org/monogram/presentation/features/chats/currentChat/ChatStoreFactory.kt b/presentation/src/main/java/org/monogram/presentation/features/chats/currentChat/ChatStoreFactory.kt index 14854e5c..bee8ce05 100644 --- a/presentation/src/main/java/org/monogram/presentation/features/chats/currentChat/ChatStoreFactory.kt +++ b/presentation/src/main/java/org/monogram/presentation/features/chats/currentChat/ChatStoreFactory.kt @@ -48,11 +48,13 @@ import org.monogram.presentation.features.chats.currentChat.impl.handleReportRea import org.monogram.presentation.features.chats.currentChat.impl.handleRetractVote import org.monogram.presentation.features.chats.currentChat.impl.handleSaveEditedMessage import org.monogram.presentation.features.chats.currentChat.impl.handleSendAlbum +import org.monogram.presentation.features.chats.currentChat.impl.handleSendDocument import org.monogram.presentation.features.chats.currentChat.impl.handleSendGif import org.monogram.presentation.features.chats.currentChat.impl.handleSendGifFile import org.monogram.presentation.features.chats.currentChat.impl.handleSendInlineResult import org.monogram.presentation.features.chats.currentChat.impl.handleSendMessage import org.monogram.presentation.features.chats.currentChat.impl.handleSendPhoto +import org.monogram.presentation.features.chats.currentChat.impl.handleSendPoll import org.monogram.presentation.features.chats.currentChat.impl.handleSendReaction import org.monogram.presentation.features.chats.currentChat.impl.handleSendScheduledNow import org.monogram.presentation.features.chats.currentChat.impl.handleSendSticker @@ -107,6 +109,18 @@ class ChatStoreFactory( ) is Intent.SendGif -> component.handleSendGif(intent.gif) + is Intent.SendDocument -> component.handleSendDocument( + path = intent.path, + caption = intent.caption, + captionEntities = intent.captionEntities, + sendOptions = intent.sendOptions + ) + + is Intent.SendPoll -> component.handleSendPoll( + poll = intent.poll, + sendOptions = intent.sendOptions + ) + is Intent.SendGifFile -> component.handleSendGifFile( path = intent.path, caption = intent.caption, diff --git a/presentation/src/main/java/org/monogram/presentation/features/chats/currentChat/DefaultChatComponent.kt b/presentation/src/main/java/org/monogram/presentation/features/chats/currentChat/DefaultChatComponent.kt index 88174293..196af5aa 100644 --- a/presentation/src/main/java/org/monogram/presentation/features/chats/currentChat/DefaultChatComponent.kt +++ b/presentation/src/main/java/org/monogram/presentation/features/chats/currentChat/DefaultChatComponent.kt @@ -33,6 +33,7 @@ import org.monogram.domain.models.MessageEntity import org.monogram.domain.models.MessageModel import org.monogram.domain.models.MessageSendOptions import org.monogram.domain.models.MessageViewerModel +import org.monogram.domain.models.PollDraft import org.monogram.domain.models.UserModel import org.monogram.domain.models.WallpaperModel import org.monogram.domain.repository.BotPreferencesProvider @@ -332,6 +333,25 @@ class DefaultChatComponent( ) = store.accept(ChatStore.Intent.SendVideo(videoPath, caption, captionEntities, sendOptions)) override fun onSendGif(gif: GifModel) = store.accept(ChatStore.Intent.SendGif(gif)) + override fun onSendDocument( + documentPath: String, + caption: String, + captionEntities: List, + sendOptions: MessageSendOptions + ) = store.accept( + ChatStore.Intent.SendDocument( + documentPath, + caption, + captionEntities, + sendOptions + ) + ) + + override fun onSendPoll( + poll: PollDraft, + sendOptions: MessageSendOptions + ) = store.accept(ChatStore.Intent.SendPoll(poll, sendOptions)) + override fun onSendGifFile( path: String, caption: String, diff --git a/presentation/src/main/java/org/monogram/presentation/features/chats/currentChat/components/ChatInputBar.kt b/presentation/src/main/java/org/monogram/presentation/features/chats/currentChat/components/ChatInputBar.kt index 27b4ad95..7727cb81 100644 --- a/presentation/src/main/java/org/monogram/presentation/features/chats/currentChat/components/ChatInputBar.kt +++ b/presentation/src/main/java/org/monogram/presentation/features/chats/currentChat/components/ChatInputBar.kt @@ -40,18 +40,18 @@ import androidx.compose.material3.Surface import androidx.compose.material3.Text import androidx.compose.material3.rememberModalBottomSheetState import androidx.compose.runtime.Composable -import androidx.compose.runtime.derivedStateOf import androidx.compose.runtime.Immutable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.derivedStateOf import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableIntStateOf import androidx.compose.runtime.mutableStateMapOf import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.rememberUpdatedState -import androidx.compose.runtime.setValue import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.focus.FocusRequester @@ -77,6 +77,7 @@ import org.monogram.domain.models.KeyboardButtonModel import org.monogram.domain.models.MessageEntity import org.monogram.domain.models.MessageModel import org.monogram.domain.models.MessageSendOptions +import org.monogram.domain.models.PollDraft import org.monogram.domain.models.ReplyMarkupModel import org.monogram.domain.models.StickerModel import org.monogram.domain.models.UserModel @@ -94,6 +95,7 @@ import org.monogram.presentation.features.chats.currentChat.components.inputbar. import org.monogram.presentation.features.chats.currentChat.components.inputbar.applyMentionSuggestion import org.monogram.presentation.features.chats.currentChat.components.inputbar.buildEditingMessageTextValue import org.monogram.presentation.features.chats.currentChat.components.inputbar.buildScheduledDateEpochSeconds +import org.monogram.presentation.features.chats.currentChat.components.inputbar.copyUriToTempDocumentPath import org.monogram.presentation.features.chats.currentChat.components.inputbar.copyUriToTempPath import org.monogram.presentation.features.chats.currentChat.components.inputbar.declaredPermissions import org.monogram.presentation.features.chats.currentChat.components.inputbar.extractEntities @@ -102,6 +104,7 @@ import org.monogram.presentation.features.chats.currentChat.components.inputbar. import org.monogram.presentation.features.chats.currentChat.components.inputbar.parseInlineQueryInput import org.monogram.presentation.features.chats.currentChat.components.inputbar.rememberVoiceRecorder import org.monogram.presentation.features.gallery.GalleryScreen +import org.monogram.presentation.features.gallery.components.PollComposerSheet import java.text.DateFormat import java.util.Calendar import java.util.Date @@ -114,6 +117,7 @@ data class ChatInputBarState( val editingMessage: MessageModel? = null, val draftText: String = "", val pendingMediaPaths: List = emptyList(), + val pendingDocumentPaths: List = emptyList(), val isClosed: Boolean = false, val permissions: ChatPermissionsModel = ChatPermissionsModel(), val slowModeDelay: Int = 0, @@ -152,8 +156,11 @@ data class ChatInputBarActions( val onTyping: () -> Unit = {}, val onCancelMedia: () -> Unit = {}, val onSendMedia: (List, String, List, MessageSendOptions) -> Unit = { _, _, _, _ -> }, + val onSendDocuments: (List, String, List, MessageSendOptions) -> Unit = { _, _, _, _ -> }, val onMediaOrderChange: (List) -> Unit = {}, + val onDocumentOrderChange: (List) -> Unit = {}, val onMediaClick: (String) -> Unit = {}, + val onSendPoll: (PollDraft) -> Unit = {}, val onShowBotCommands: () -> Unit = {}, val onReplyMarkupButtonClick: (KeyboardButtonModel) -> Unit = {}, val onOpenMiniApp: (String, String) -> Unit = { _, _ -> }, @@ -218,6 +225,9 @@ fun ChatInputBar( val canSendVoice by remember(state.isChannel, state.isAdmin, state.permissions.canSendVoiceNotes) { derivedStateOf { if (state.isChannel) true else (state.isAdmin || state.permissions.canSendVoiceNotes) } } + val canSendPolls by remember(state.isChannel, state.isAdmin, state.permissions.canSendPolls) { + derivedStateOf { if (state.isChannel) true else (state.isAdmin || state.permissions.canSendPolls) } + } val canSendVideoNotes by remember(state.isChannel, state.isAdmin, state.permissions.canSendVideoNotes) { derivedStateOf { if (state.isChannel) true else (state.isAdmin || state.permissions.canSendVideoNotes) } } @@ -240,6 +250,7 @@ fun ChatInputBar( var isGifSearchFocused by remember { mutableStateOf(false) } var showGallery by remember { mutableStateOf(false) } var showCamera by remember { mutableStateOf(false) } + var showPollComposer by rememberSaveable { mutableStateOf(false) } var showFullScreenEditor by rememberSaveable { mutableStateOf(false) } var showSendOptionsSheet by rememberSaveable { mutableStateOf(false) } var showScheduleDatePicker by rememberSaveable { mutableStateOf(false) } @@ -303,6 +314,16 @@ fun ChatInputBar( } } + LaunchedEffect(showPollComposer) { + if (showPollComposer) { + openStickerMenuAfterKeyboardClosed = false + openKeyboardAfterStickerMenuClosed = false + closeStickerMenuWithoutSlide = false + isStickerMenuVisible = false + hideKeyboardAndClearFocus() + } + } + LaunchedEffect(canSendStickers) { if (!canSendStickers && isStickerMenuVisible) { isStickerMenuVisible = false @@ -351,8 +372,15 @@ fun ChatInputBar( actions.onSendVoice(path, duration, waveform) activateSlowModeCooldown() } - val maxMessageLength by remember(state.pendingMediaPaths, state.isPremiumUser) { - derivedStateOf { if (state.pendingMediaPaths.isNotEmpty() && !state.isPremiumUser) 1024 else 4096 } + val maxMessageLength by remember( + state.pendingMediaPaths, + state.pendingDocumentPaths, + state.isPremiumUser + ) { + derivedStateOf { + if ((state.pendingMediaPaths.isNotEmpty() || state.pendingDocumentPaths.isNotEmpty()) && !state.isPremiumUser) 1024 + else 4096 + } } val currentMessageLength by remember(textValue.text) { derivedStateOf { textValue.text.length } @@ -370,6 +398,7 @@ fun ChatInputBar( val canSendNow = when { state.pendingMediaPaths.isNotEmpty() && canSendMedia -> true + state.pendingDocumentPaths.isNotEmpty() && canSendMedia -> true state.editingMessage != null -> false canWriteText && !isTextEmpty -> true else -> false @@ -384,6 +413,11 @@ fun ChatInputBar( textValue = TextFieldValue("") knownCustomEmojis.clear() sentInstantMessage = !isScheduling + } else if (state.pendingDocumentPaths.isNotEmpty() && canSendMedia) { + actions.onSendDocuments(state.pendingDocumentPaths, textValue.text, captionEntities, it) + textValue = TextFieldValue("") + knownCustomEmojis.clear() + sentInstantMessage = !isScheduling } else if (state.editingMessage != null && canWriteText) { if (!isTextEmpty) { actions.onSaveEdit(textValue.text, captionEntities) @@ -497,7 +531,7 @@ fun ChatInputBar( } } - BackHandler(enabled = isStickerMenuVisible || openStickerMenuAfterKeyboardClosed || openKeyboardAfterStickerMenuClosed || state.pendingMediaPaths.isNotEmpty() || showGallery || showCamera || showFullScreenEditor || showSendOptionsSheet || showScheduledMessagesSheet || showScheduleDatePicker || showScheduleTimePicker) { + BackHandler(enabled = isStickerMenuVisible || openStickerMenuAfterKeyboardClosed || openKeyboardAfterStickerMenuClosed || state.pendingMediaPaths.isNotEmpty() || state.pendingDocumentPaths.isNotEmpty() || showGallery || showCamera || showPollComposer || showFullScreenEditor || showSendOptionsSheet || showScheduledMessagesSheet || showScheduleDatePicker || showScheduleTimePicker) { if (isGifSearchFocused) { focusManager.clearFocus() } else if (openStickerMenuAfterKeyboardClosed) { @@ -521,8 +555,12 @@ fun ChatInputBar( showFullScreenEditor = false } else if (state.pendingMediaPaths.isNotEmpty()) { actions.onCancelMedia() + } else if (state.pendingDocumentPaths.isNotEmpty()) { + actions.onDocumentOrderChange(emptyList()) } else if (showGallery) { showGallery = false + } else if (showPollComposer) { + showPollComposer = false } else if (showCamera) { showCamera = false } @@ -597,12 +635,22 @@ fun ChatInputBar( hasCameraPermission.value = granted if (granted) showCamera = true } + val documentsPickerLauncher = rememberLauncherForActivityResult( + ActivityResultContracts.OpenMultipleDocuments() + ) { uris -> + val localPaths = uris.mapNotNull { uri -> context.copyUriToTempDocumentPath(uri) } + if (localPaths.isNotEmpty()) { + actions.onDocumentOrderChange((state.pendingDocumentPaths + localPaths).distinct()) + actions.onMediaOrderChange(emptyList()) + } + } val inputBarMode by remember( canSendAnything, isSlowModeActive, textValue.text, state.pendingMediaPaths, + state.pendingDocumentPaths, state.editingMessage, voiceRecorder.isRecording ) { @@ -612,6 +660,7 @@ fun ChatInputBar( isSlowModeActive && textValue.text.isBlank() && state.pendingMediaPaths.isEmpty() && + state.pendingDocumentPaths.isEmpty() && state.editingMessage == null && !voiceRecorder.isRecording -> InputBarMode.SlowMode @@ -626,6 +675,7 @@ fun ChatInputBar( val path = context.copyUriToTempPath(uri) if (path != null) { actions.onMediaOrderChange((state.pendingMediaPaths + path).distinct()) + actions.onDocumentOrderChange(emptyList()) } showCamera = false }, @@ -648,6 +698,7 @@ fun ChatInputBar( editingMessage = state.editingMessage, replyMessage = state.replyMessage, pendingMediaPaths = state.pendingMediaPaths, + pendingDocumentPaths = state.pendingDocumentPaths, mentionSuggestions = state.mentionSuggestions, filteredCommands = filteredCommands, currentInlineBotUsername = state.currentInlineBotUsername, @@ -688,7 +739,9 @@ fun ChatInputBar( onCancelEdit = actions.onCancelEdit, onCancelReply = actions.onCancelReply, onCancelMedia = actions.onCancelMedia, + onCancelDocuments = { actions.onDocumentOrderChange(emptyList()) }, onMediaOrderChange = actions.onMediaOrderChange, + onDocumentOrderChange = actions.onDocumentOrderChange, onMediaClick = actions.onMediaClick, onPasteImages = { uris -> if (!canSendMedia || state.editingMessage != null) return@ChatInputBarComposerSection @@ -697,6 +750,7 @@ fun ChatInputBar( } if (localPaths.isNotEmpty()) { actions.onMediaOrderChange((state.pendingMediaPaths + localPaths).distinct()) + actions.onDocumentOrderChange(emptyList()) } }, onMentionClick = { user -> @@ -846,7 +900,7 @@ fun ChatInputBar( textValue = textValue, onTextValueChange = { textValue = it }, canWriteText = canWriteText, - pendingMediaPaths = state.pendingMediaPaths, + pendingMediaPaths = if (state.pendingMediaPaths.isNotEmpty()) state.pendingMediaPaths else state.pendingDocumentPaths, knownCustomEmojis = knownCustomEmojis, emojiFontFamily = emojiFontFamily, isKeyboardVisible = isKeyboardVisible, @@ -942,6 +996,7 @@ fun ChatInputBar( } if (localPaths.isNotEmpty()) { actions.onMediaOrderChange((state.pendingMediaPaths + localPaths).distinct()) + actions.onDocumentOrderChange(emptyList()) } showGallery = false }, @@ -958,6 +1013,17 @@ fun ChatInputBar( cameraPermissionLauncher.launch(Manifest.permission.CAMERA) } }, + canCreatePoll = canSendPolls && !state.isSecretChat, + onAttachFileClick = { + showGallery = false + documentsPickerLauncher.launch(arrayOf("*/*")) + }, + onCreatePollClick = { + if (canSendPolls && !state.isSecretChat) { + showGallery = false + showPollComposer = true + } + }, attachBots = state.attachBots, hasMediaAccess = hasGalleryPermission.value || hasFullGalleryPermission() || hasPartialGalleryPermission(), isPartialAccess = isPartialGalleryAccess, @@ -978,6 +1044,18 @@ fun ChatInputBar( ) } } + + if (showPollComposer) { + PollComposerSheet( + onDismiss = { showPollComposer = false }, + onCreatePoll = { poll: PollDraft -> + if (isSlowModeActive) return@PollComposerSheet + showPollComposer = false + actions.onSendPoll(poll) + activateSlowModeCooldown() + } + ) + } } @Composable diff --git a/presentation/src/main/java/org/monogram/presentation/features/chats/currentChat/components/inputbar/ChatInputBarComposerSection.kt b/presentation/src/main/java/org/monogram/presentation/features/chats/currentChat/components/inputbar/ChatInputBarComposerSection.kt index 82f5abbe..1804c68f 100644 --- a/presentation/src/main/java/org/monogram/presentation/features/chats/currentChat/components/inputbar/ChatInputBarComposerSection.kt +++ b/presentation/src/main/java/org/monogram/presentation/features/chats/currentChat/components/inputbar/ChatInputBarComposerSection.kt @@ -63,6 +63,7 @@ fun ChatInputBarComposerSection( editingMessage: MessageModel?, replyMessage: MessageModel?, pendingMediaPaths: List, + pendingDocumentPaths: List, mentionSuggestions: List, filteredCommands: List, currentInlineBotUsername: String?, @@ -104,7 +105,9 @@ fun ChatInputBarComposerSection( onCancelEdit: () -> Unit, onCancelReply: () -> Unit, onCancelMedia: () -> Unit, + onCancelDocuments: () -> Unit, onMediaOrderChange: (List) -> Unit, + onDocumentOrderChange: (List) -> Unit, onMediaClick: (String) -> Unit, onPasteImages: (List) -> Unit, onMentionClick: (UserModel) -> Unit, @@ -152,10 +155,13 @@ fun ChatInputBarComposerSection( editingMessage = editingMessage, replyMessage = replyMessage, pendingMediaPaths = pendingMediaPaths, + pendingDocumentPaths = pendingDocumentPaths, onCancelEdit = onCancelEdit, onCancelReply = onCancelReply, onCancelMedia = onCancelMedia, + onCancelDocuments = onCancelDocuments, onMediaOrderChange = onMediaOrderChange, + onDocumentOrderChange = onDocumentOrderChange, onMediaClick = onMediaClick ) @@ -219,6 +225,7 @@ fun ChatInputBarComposerSection( InputBarLeadingIcons( editingMessage = editingMessage, pendingMediaPaths = pendingMediaPaths, + pendingDocumentPaths = pendingDocumentPaths, canSendMedia = canSendMedia, onAttachClick = onAttachClick ) @@ -266,6 +273,7 @@ fun ChatInputBarComposerSection( emojiFontFamily = emojiFontFamily, focusRequester = focusRequester, pendingMediaPaths = pendingMediaPaths, + pendingDocumentPaths = pendingDocumentPaths, canPasteMediaFromClipboard = canPasteMediaFromClipboard, onPasteImages = onPasteImages, onFocus = onInputFocus, @@ -294,6 +302,7 @@ fun ChatInputBarComposerSection( textValue = textValue, editingMessage = editingMessage, pendingMediaPaths = pendingMediaPaths, + pendingDocumentPaths = pendingDocumentPaths, isOverCharLimit = isOverMessageLimit, canWriteText = canWriteText, canSendVoice = canSendVoice, diff --git a/presentation/src/main/java/org/monogram/presentation/features/chats/currentChat/components/inputbar/ChatInputBarHelpers.kt b/presentation/src/main/java/org/monogram/presentation/features/chats/currentChat/components/inputbar/ChatInputBarHelpers.kt index 1f665d81..6c943b31 100644 --- a/presentation/src/main/java/org/monogram/presentation/features/chats/currentChat/components/inputbar/ChatInputBarHelpers.kt +++ b/presentation/src/main/java/org/monogram/presentation/features/chats/currentChat/components/inputbar/ChatInputBarHelpers.kt @@ -2,12 +2,19 @@ package org.monogram.presentation.features.chats.currentChat.components.inputbar import android.content.Context import android.content.pm.PackageManager +import android.provider.OpenableColumns import androidx.compose.ui.text.AnnotatedString import androidx.compose.ui.text.TextRange import androidx.compose.ui.text.buildAnnotatedString import androidx.compose.ui.text.input.TextFieldValue import androidx.core.content.ContextCompat -import org.monogram.domain.models.* +import org.monogram.domain.models.MessageContent +import org.monogram.domain.models.MessageEntity +import org.monogram.domain.models.MessageEntityType +import org.monogram.domain.models.MessageModel +import org.monogram.domain.models.StickerFormat +import org.monogram.domain.models.StickerModel +import org.monogram.domain.models.UserModel import java.io.File import java.io.FileOutputStream @@ -180,3 +187,36 @@ internal fun Context.copyUriToTempPath(uri: android.net.Uri): String? { null } } + +internal fun Context.copyUriToTempDocumentPath(uri: android.net.Uri): String? { + return try { + if (uri.scheme == "file") return uri.path + + val displayName = + contentResolver.query(uri, arrayOf(OpenableColumns.DISPLAY_NAME), null, null, null) + ?.use { cursor -> + if (cursor.moveToFirst()) { + val index = cursor.getColumnIndex(OpenableColumns.DISPLAY_NAME) + if (index >= 0) cursor.getString(index) else null + } else { + null + } + } + ?.trim() + .orEmpty() + + val mime = contentResolver.getType(uri).orEmpty() + val safeName = when { + displayName.isNotBlank() -> displayName.replace(Regex("[\\\\/:*?\"<>|]"), "_") + mime.startsWith("application/pdf") -> "document_${System.nanoTime()}.pdf" + else -> "document_${System.nanoTime()}.bin" + } + val file = File(cacheDir, "doc_${System.nanoTime()}_$safeName") + contentResolver.openInputStream(uri)?.use { input -> + FileOutputStream(file).use { output -> input.copyTo(output) } + } ?: return null + file.absolutePath + } catch (_: Exception) { + null + } +} diff --git a/presentation/src/main/java/org/monogram/presentation/features/chats/currentChat/components/inputbar/FullScreenEditorSheet.kt b/presentation/src/main/java/org/monogram/presentation/features/chats/currentChat/components/inputbar/FullScreenEditorSheet.kt index dc80821b..780aa35e 100644 --- a/presentation/src/main/java/org/monogram/presentation/features/chats/currentChat/components/inputbar/FullScreenEditorSheet.kt +++ b/presentation/src/main/java/org/monogram/presentation/features/chats/currentChat/components/inputbar/FullScreenEditorSheet.kt @@ -2,23 +2,92 @@ package org.monogram.presentation.features.chats.currentChat.components.inputbar import android.content.ClipData import android.widget.Toast -import androidx.compose.animation.* -import androidx.compose.foundation.* -import androidx.compose.foundation.layout.* +import androidx.compose.animation.AnimatedContent +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.animation.animateContentSize +import androidx.compose.animation.expandVertically +import androidx.compose.animation.fadeIn +import androidx.compose.animation.fadeOut +import androidx.compose.animation.shrinkVertically +import androidx.compose.foundation.BorderStroke +import androidx.compose.foundation.background +import androidx.compose.foundation.combinedClickable +import androidx.compose.foundation.horizontalScroll +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.heightIn +import androidx.compose.foundation.layout.imePadding +import androidx.compose.foundation.layout.navigationBarsPadding +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.statusBarsPadding +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.selection.selectable import androidx.compose.foundation.selection.selectableGroup import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.text.InlineTextContent import androidx.compose.foundation.text.appendInlineContent +import androidx.compose.foundation.verticalScroll import androidx.compose.material.icons.Icons import androidx.compose.material.icons.automirrored.filled.ArrowBack import androidx.compose.material.icons.automirrored.outlined.Subject import androidx.compose.material.icons.filled.Check -import androidx.compose.material.icons.outlined.* -import androidx.compose.material3.* -import androidx.compose.runtime.* +import androidx.compose.material.icons.outlined.AlternateEmail +import androidx.compose.material.icons.outlined.AutoAwesome +import androidx.compose.material.icons.outlined.Close +import androidx.compose.material.icons.outlined.Code +import androidx.compose.material.icons.outlined.ContentCopy +import androidx.compose.material.icons.outlined.ContentCut +import androidx.compose.material.icons.outlined.ContentPaste +import androidx.compose.material.icons.outlined.FormatBold +import androidx.compose.material.icons.outlined.FormatClear +import androidx.compose.material.icons.outlined.FormatItalic +import androidx.compose.material.icons.outlined.FormatStrikethrough +import androidx.compose.material.icons.outlined.FormatUnderlined +import androidx.compose.material.icons.outlined.Link +import androidx.compose.material.icons.outlined.Translate +import androidx.compose.material.icons.outlined.Visibility +import androidx.compose.material.icons.outlined.VisibilityOff +import androidx.compose.material3.AlertDialog +import androidx.compose.material3.BottomSheetDefaults +import androidx.compose.material3.Button +import androidx.compose.material3.DropdownMenuItem +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.ExposedDropdownMenuAnchorType +import androidx.compose.material3.ExposedDropdownMenuBox +import androidx.compose.material3.ExposedDropdownMenuDefaults +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.LinearProgressIndicator +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.ModalBottomSheet +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.Surface +import androidx.compose.material3.Switch +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.material3.rememberModalBottomSheetState +import androidx.compose.runtime.Composable +import androidx.compose.runtime.DisposableEffect +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableFloatStateOf +import androidx.compose.runtime.mutableIntStateOf +import androidx.compose.runtime.mutableStateListOf +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip @@ -26,12 +95,16 @@ import androidx.compose.ui.focus.FocusRequester import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.luminance import androidx.compose.ui.graphics.vector.ImageVector -import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalClipboard +import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalView import androidx.compose.ui.res.stringResource import androidx.compose.ui.semantics.Role -import androidx.compose.ui.text.* +import androidx.compose.ui.text.AnnotatedString +import androidx.compose.ui.text.Placeholder +import androidx.compose.ui.text.PlaceholderVerticalAlign +import androidx.compose.ui.text.TextRange +import androidx.compose.ui.text.buildAnnotatedString import androidx.compose.ui.text.font.FontFamily import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.input.TextFieldValue @@ -48,7 +121,12 @@ import kotlinx.coroutines.launch import org.koin.compose.koinInject import org.monogram.domain.models.MessageEntityType import org.monogram.domain.models.StickerModel -import org.monogram.domain.repository.* +import org.monogram.domain.repository.EditorSnippet +import org.monogram.domain.repository.EditorSnippetProvider +import org.monogram.domain.repository.FormattedTextResult +import org.monogram.domain.repository.MessageAiRepository +import org.monogram.domain.repository.StickerRepository +import org.monogram.domain.repository.TextCompositionStyleModel import org.monogram.presentation.R import org.monogram.presentation.core.ui.ItemPosition import org.monogram.presentation.features.chats.chatList.components.SettingsTextField @@ -56,7 +134,7 @@ import org.monogram.presentation.features.chats.currentChat.components.chats.add import org.monogram.presentation.features.profile.logs.components.calculateDiff import org.monogram.presentation.features.stickers.ui.menu.StickerEmojiMenu import org.monogram.presentation.features.stickers.ui.view.StickerImage -import java.util.* +import java.util.Locale private enum class AiEditorMode { Translate, @@ -555,6 +633,7 @@ fun FullScreenEditorSheet( emojiFontFamily = emojiFontFamily, focusRequester = focusRequester, pendingMediaPaths = pendingMediaPaths, + pendingDocumentPaths = emptyList(), fontScale = fontScale, maxEditorHeight = 860.dp, onFocus = onEditorFocus, diff --git a/presentation/src/main/java/org/monogram/presentation/features/chats/currentChat/components/inputbar/InputBarLeadingIcons.kt b/presentation/src/main/java/org/monogram/presentation/features/chats/currentChat/components/inputbar/InputBarLeadingIcons.kt index 9179d9d8..98c7a4a1 100644 --- a/presentation/src/main/java/org/monogram/presentation/features/chats/currentChat/components/inputbar/InputBarLeadingIcons.kt +++ b/presentation/src/main/java/org/monogram/presentation/features/chats/currentChat/components/inputbar/InputBarLeadingIcons.kt @@ -1,6 +1,12 @@ package org.monogram.presentation.features.chats.currentChat.components.inputbar -import androidx.compose.animation.* +import androidx.compose.animation.AnimatedContent +import androidx.compose.animation.SizeTransform +import androidx.compose.animation.fadeIn +import androidx.compose.animation.fadeOut +import androidx.compose.animation.scaleIn +import androidx.compose.animation.scaleOut +import androidx.compose.animation.togetherWith import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.width import androidx.compose.material.icons.Icons @@ -19,10 +25,12 @@ import org.monogram.presentation.R fun InputBarLeadingIcons( editingMessage: MessageModel?, pendingMediaPaths: List, + pendingDocumentPaths: List, canSendMedia: Boolean, onAttachClick: () -> Unit ) { - val canAttachMedia = editingMessage == null && pendingMediaPaths.isEmpty() && canSendMedia + val canAttachMedia = + editingMessage == null && pendingMediaPaths.isEmpty() && pendingDocumentPaths.isEmpty() && canSendMedia AnimatedContent( targetState = canAttachMedia, diff --git a/presentation/src/main/java/org/monogram/presentation/features/chats/currentChat/components/inputbar/InputBarSendButton.kt b/presentation/src/main/java/org/monogram/presentation/features/chats/currentChat/components/inputbar/InputBarSendButton.kt index 09459e4b..d43d5edf 100644 --- a/presentation/src/main/java/org/monogram/presentation/features/chats/currentChat/components/inputbar/InputBarSendButton.kt +++ b/presentation/src/main/java/org/monogram/presentation/features/chats/currentChat/components/inputbar/InputBarSendButton.kt @@ -1,7 +1,16 @@ package org.monogram.presentation.features.chats.currentChat.components.inputbar -import androidx.compose.animation.* +import androidx.compose.animation.AnimatedContent +import androidx.compose.animation.SizeTransform +import androidx.compose.animation.animateColorAsState import androidx.compose.animation.core.tween +import androidx.compose.animation.fadeIn +import androidx.compose.animation.fadeOut +import androidx.compose.animation.scaleIn +import androidx.compose.animation.scaleOut +import androidx.compose.animation.slideInVertically +import androidx.compose.animation.slideOutVertically +import androidx.compose.animation.togetherWith import androidx.compose.foundation.ExperimentalFoundationApi import androidx.compose.foundation.background import androidx.compose.foundation.combinedClickable @@ -19,7 +28,11 @@ import androidx.compose.material.icons.outlined.Mic import androidx.compose.material3.Icon import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text -import androidx.compose.runtime.* +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip @@ -40,6 +53,7 @@ fun InputBarSendButton( textValue: TextFieldValue, editingMessage: MessageModel?, pendingMediaPaths: List, + pendingDocumentPaths: List, isOverCharLimit: Boolean, canWriteText: Boolean, canSendVoice: Boolean, @@ -58,10 +72,11 @@ fun InputBarSendButton( ) { val haptic = LocalHapticFeedback.current val isTextEmpty = textValue.text.isBlank() - val canSendContent = canWriteText || (pendingMediaPaths.isNotEmpty() && canSendMedia) + val hasPendingAttachments = pendingMediaPaths.isNotEmpty() || pendingDocumentPaths.isNotEmpty() + val canSendContent = canWriteText || (hasPendingAttachments && canSendMedia) val isSlowModeBlocked = isSlowModeActive && editingMessage == null val isSendEnabled = - (!isTextEmpty || editingMessage != null || pendingMediaPaths.isNotEmpty()) && + (!isTextEmpty || editingMessage != null || hasPendingAttachments) && canSendContent && !isOverCharLimit && !isSlowModeBlocked @@ -75,7 +90,7 @@ fun InputBarSendButton( val canUseRecording = canSendVoice || canSendVideoNotes val canToggleRecordingMode = canSendVoice && canSendVideoNotes val isRecordingMode = - isTextEmpty && editingMessage == null && pendingMediaPaths.isEmpty() && canUseRecording && !isSlowModeBlocked + isTextEmpty && editingMessage == null && !hasPendingAttachments && canUseRecording && !isSlowModeBlocked val backgroundColor by animateColorAsState( targetValue = if (isSendEnabled || isVoiceRecordingActive || isSlowModeBlocked) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.surfaceVariant, @@ -90,14 +105,14 @@ fun InputBarSendButton( if (canWriteText || canSendVoice || canSendVideoNotes) { val sendIcon = when { - pendingMediaPaths.isNotEmpty() -> Icons.AutoMirrored.Filled.Send + hasPendingAttachments -> Icons.AutoMirrored.Filled.Send editingMessage != null -> Icons.Default.Check !isTextEmpty -> Icons.AutoMirrored.Filled.Send effectiveVideoMode -> Icons.Default.Videocam else -> Icons.Outlined.Mic } val canShowOptions = editingMessage == null && canWriteText && - (!isTextEmpty || (pendingMediaPaths.isNotEmpty() && canSendMedia)) && + (!isTextEmpty || (hasPendingAttachments && canSendMedia)) && !isOverCharLimit && !isSlowModeBlocked diff --git a/presentation/src/main/java/org/monogram/presentation/features/chats/currentChat/components/inputbar/InputPreviewSection.kt b/presentation/src/main/java/org/monogram/presentation/features/chats/currentChat/components/inputbar/InputPreviewSection.kt index b471d8b1..77bc2e36 100644 --- a/presentation/src/main/java/org/monogram/presentation/features/chats/currentChat/components/inputbar/InputPreviewSection.kt +++ b/presentation/src/main/java/org/monogram/presentation/features/chats/currentChat/components/inputbar/InputPreviewSection.kt @@ -1,13 +1,29 @@ package org.monogram.presentation.features.chats.currentChat.components.inputbar -import androidx.compose.animation.* +import androidx.compose.animation.AnimatedContent import androidx.compose.animation.core.animateFloatAsState import androidx.compose.animation.core.tween +import androidx.compose.animation.expandVertically +import androidx.compose.animation.fadeIn +import androidx.compose.animation.fadeOut +import androidx.compose.animation.shrinkVertically +import androidx.compose.animation.togetherWith import androidx.compose.foundation.ExperimentalFoundationApi import androidx.compose.foundation.background import androidx.compose.foundation.clickable import androidx.compose.foundation.gestures.detectDragGesturesAfterLongPress -import androidx.compose.foundation.layout.* +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width import androidx.compose.foundation.lazy.LazyRow import androidx.compose.foundation.lazy.itemsIndexed import androidx.compose.foundation.shape.CircleShape @@ -21,7 +37,11 @@ import androidx.compose.material3.Icon import androidx.compose.material3.IconButton import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text -import androidx.compose.runtime.* +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip @@ -43,13 +63,14 @@ import org.monogram.presentation.R import org.monogram.presentation.features.chats.currentChat.components.chats.buildAnnotatedMessageTextWithEmoji import org.monogram.presentation.features.chats.currentChat.components.chats.rememberMessageInlineContent import java.io.File -import java.util.* +import java.util.Collections sealed class InputPreviewState { object None : InputPreviewState() data class Reply(val message: MessageModel) : InputPreviewState() data class Edit(val message: MessageModel) : InputPreviewState() data class Media(val paths: List) : InputPreviewState() + data class Documents(val paths: List) : InputPreviewState() } @Composable @@ -57,15 +78,20 @@ fun InputPreviewSection( editingMessage: MessageModel?, replyMessage: MessageModel?, pendingMediaPaths: List, + pendingDocumentPaths: List, onCancelEdit: () -> Unit, onCancelReply: () -> Unit, onCancelMedia: () -> Unit, + onCancelDocuments: () -> Unit, onMediaOrderChange: (List) -> Unit, + onDocumentOrderChange: (List) -> Unit, onMediaClick: (String) -> Unit ) { - val previewState = remember(editingMessage, replyMessage, pendingMediaPaths) { + val previewState = + remember(editingMessage, replyMessage, pendingMediaPaths, pendingDocumentPaths) { when { pendingMediaPaths.isNotEmpty() -> InputPreviewState.Media(pendingMediaPaths) + pendingDocumentPaths.isNotEmpty() -> InputPreviewState.Documents(pendingDocumentPaths) editingMessage != null -> InputPreviewState.Edit(editingMessage) replyMessage != null -> InputPreviewState.Reply(replyMessage) else -> InputPreviewState.None @@ -101,12 +127,102 @@ fun InputPreviewSection( }, onMediaClick = onMediaClick ) + is InputPreviewState.Documents -> DocumentPreview( + paths = state.paths, + onCancel = onCancelDocuments, + onRemove = { path -> + val newList = pendingDocumentPaths.toMutableList() + newList.remove(path) + onDocumentOrderChange(newList) + } + ) InputPreviewState.None -> Spacer(modifier = Modifier.height(0.dp)) } } } +@Composable +private fun DocumentPreview( + paths: List, + onCancel: () -> Unit, + onRemove: (String) -> Unit +) { + Column( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 8.dp, vertical = 4.dp) + .background(MaterialTheme.colorScheme.surfaceContainer, RoundedCornerShape(12.dp)) + .padding(12.dp), + verticalArrangement = Arrangement.spacedBy(8.dp) + ) { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically + ) { + Text( + text = stringResource(R.string.action_attach_file_count, paths.size), + style = MaterialTheme.typography.labelMedium, + color = MaterialTheme.colorScheme.primary + ) + IconButton(onClick = onCancel, modifier = Modifier.size(24.dp)) { + Icon( + imageVector = Icons.Default.Close, + contentDescription = stringResource(R.string.action_cancel), + tint = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.size(18.dp) + ) + } + } + + paths.forEach { path -> + val file = File(path) + Row( + modifier = Modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.SpaceBetween + ) { + Column(modifier = Modifier.weight(1f)) { + Text( + text = file.name.ifBlank { "File" }, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + style = MaterialTheme.typography.bodyMedium + ) + Text( + text = formatFileSize(file.length()), + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + IconButton(onClick = { onRemove(path) }) { + Icon( + imageVector = Icons.Default.Close, + contentDescription = stringResource(R.string.action_remove) + ) + } + } + } + } +} + +private fun formatFileSize(bytes: Long): String { + if (bytes <= 0L) return "0 B" + val units = listOf("B", "KB", "MB", "GB") + var value = bytes.toDouble() + var index = 0 + while (value >= 1024 && index < units.lastIndex) { + value /= 1024.0 + index++ + } + return if (index == 0) { + "${value.toInt()} ${units[index]}" + } else { + String.format("%.1f %s", value, units[index]) + } +} + @Composable private fun ReplyPreview( message: MessageModel, diff --git a/presentation/src/main/java/org/monogram/presentation/features/chats/currentChat/components/inputbar/InputTextField.kt b/presentation/src/main/java/org/monogram/presentation/features/chats/currentChat/components/inputbar/InputTextField.kt index a50d92fc..dd248305 100644 --- a/presentation/src/main/java/org/monogram/presentation/features/chats/currentChat/components/inputbar/InputTextField.kt +++ b/presentation/src/main/java/org/monogram/presentation/features/chats/currentChat/components/inputbar/InputTextField.kt @@ -109,6 +109,7 @@ fun InputTextField( emojiFontFamily: FontFamily, focusRequester: FocusRequester, pendingMediaPaths: List, + pendingDocumentPaths: List, canPasteMediaFromClipboard: Boolean = false, onPasteImages: (List) -> Unit = {}, fontScale: Float = 1f, @@ -435,7 +436,7 @@ fun InputTextField( ) { if (textValue.text.isEmpty()) { Text( - text = if (pendingMediaPaths.isNotEmpty()) + text = if (pendingMediaPaths.isNotEmpty() || pendingDocumentPaths.isNotEmpty()) stringResource(R.string.input_placeholder_caption) else stringResource(R.string.input_placeholder_message), diff --git a/presentation/src/main/java/org/monogram/presentation/features/chats/currentChat/components/inputbar/InputTextFieldContainer.kt b/presentation/src/main/java/org/monogram/presentation/features/chats/currentChat/components/inputbar/InputTextFieldContainer.kt index 833cc363..82c224f4 100644 --- a/presentation/src/main/java/org/monogram/presentation/features/chats/currentChat/components/inputbar/InputTextFieldContainer.kt +++ b/presentation/src/main/java/org/monogram/presentation/features/chats/currentChat/components/inputbar/InputTextFieldContainer.kt @@ -58,6 +58,7 @@ fun InputTextFieldContainer( emojiFontFamily: FontFamily, focusRequester: FocusRequester, pendingMediaPaths: List, + pendingDocumentPaths: List, canPasteMediaFromClipboard: Boolean = false, onPasteImages: (List) -> Unit = {}, onFocus: () -> Unit = {}, @@ -123,6 +124,7 @@ fun InputTextFieldContainer( emojiFontFamily = emojiFontFamily, focusRequester = focusRequester, pendingMediaPaths = pendingMediaPaths, + pendingDocumentPaths = pendingDocumentPaths, canPasteMediaFromClipboard = canPasteMediaFromClipboard, onPasteImages = onPasteImages, onFocus = onFocus, diff --git a/presentation/src/main/java/org/monogram/presentation/features/chats/currentChat/impl/MessageActions.kt b/presentation/src/main/java/org/monogram/presentation/features/chats/currentChat/impl/MessageActions.kt index adf33516..78c807a1 100644 --- a/presentation/src/main/java/org/monogram/presentation/features/chats/currentChat/impl/MessageActions.kt +++ b/presentation/src/main/java/org/monogram/presentation/features/chats/currentChat/impl/MessageActions.kt @@ -10,7 +10,12 @@ import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch import kotlinx.coroutines.withContext -import org.monogram.domain.models.* +import org.monogram.domain.models.GifModel +import org.monogram.domain.models.MessageContent +import org.monogram.domain.models.MessageEntity +import org.monogram.domain.models.MessageModel +import org.monogram.domain.models.MessageSendOptions +import org.monogram.domain.models.PollDraft import org.monogram.presentation.features.chats.currentChat.DefaultChatComponent import org.monogram.presentation.features.chats.currentChat.editor.video.VideoQuality import org.monogram.presentation.features.chats.currentChat.editor.video.VideoTrimRange @@ -169,6 +174,60 @@ internal fun DefaultChatComponent.handleSendGif( } } +internal fun DefaultChatComponent.handleSendDocument( + path: String, + caption: String, + captionEntities: List = emptyList(), + sendOptions: MessageSendOptions = MessageSendOptions() +) { + scope.launch { + val currentState = _state.value + val replyId = currentState.replyMessage?.id + val threadId = currentState.currentTopicId + repositoryMessage.sendDocument( + chatId = chatId, + documentPath = path, + caption = caption, + captionEntities = captionEntities, + replyToMsgId = replyId, + threadId = threadId, + sendOptions = sendOptions + ) + onCancelReply() + if (!currentState.isAtBottom) { + onScrollToBottom() + } + if (sendOptions.scheduleDate != null) { + loadScheduledMessages() + } + } +} + +internal fun DefaultChatComponent.handleSendPoll( + poll: PollDraft, + sendOptions: MessageSendOptions = MessageSendOptions() +) { + scope.launch { + val currentState = _state.value + val replyId = currentState.replyMessage?.id + val threadId = currentState.currentTopicId + repositoryMessage.sendPoll( + chatId = chatId, + poll = poll, + replyToMsgId = replyId, + threadId = threadId, + sendOptions = sendOptions + ) + onCancelReply() + if (!currentState.isAtBottom) { + onScrollToBottom() + } + if (sendOptions.scheduleDate != null) { + loadScheduledMessages() + } + } +} + internal fun DefaultChatComponent.handleSendGifFile( path: String, caption: String, diff --git a/presentation/src/main/java/org/monogram/presentation/features/gallery/GalleryScreen.kt b/presentation/src/main/java/org/monogram/presentation/features/gallery/GalleryScreen.kt index e34f1d45..7cb08c2a 100644 --- a/presentation/src/main/java/org/monogram/presentation/features/gallery/GalleryScreen.kt +++ b/presentation/src/main/java/org/monogram/presentation/features/gallery/GalleryScreen.kt @@ -1,14 +1,33 @@ package org.monogram.presentation.features.gallery import android.net.Uri -import androidx.compose.animation.* +import androidx.compose.animation.AnimatedContent +import androidx.compose.animation.AnimatedVisibility import androidx.compose.animation.core.tween -import androidx.compose.foundation.layout.* +import androidx.compose.animation.fadeIn +import androidx.compose.animation.fadeOut +import androidx.compose.animation.slideInVertically +import androidx.compose.animation.slideOutVertically +import androidx.compose.animation.togetherWith +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.WindowInsets +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.navigationBars +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.windowInsetsPadding import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Scaffold import androidx.compose.material3.Surface -import androidx.compose.runtime.* +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.derivedStateOf +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateListOf +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.res.stringResource @@ -17,13 +36,24 @@ import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.withContext import org.monogram.domain.models.AttachMenuBotModel import org.monogram.presentation.R -import org.monogram.presentation.features.gallery.components.* +import org.monogram.presentation.features.gallery.components.AttachBotsSection +import org.monogram.presentation.features.gallery.components.FolderRow +import org.monogram.presentation.features.gallery.components.GalleryGrid +import org.monogram.presentation.features.gallery.components.GalleryTabs +import org.monogram.presentation.features.gallery.components.GalleryTopBar +import org.monogram.presentation.features.gallery.components.PartialAccessCard +import org.monogram.presentation.features.gallery.components.PermissionCard +import org.monogram.presentation.features.gallery.components.SectionHeader +import org.monogram.presentation.features.gallery.components.SelectedCountCard @Composable fun GalleryScreen( onMediaSelected: (List) -> Unit, onDismiss: () -> Unit, onCameraClick: () -> Unit, + canCreatePoll: Boolean, + onAttachFileClick: () -> Unit, + onCreatePollClick: () -> Unit, attachBots: List, hasMediaAccess: Boolean, isPartialAccess: Boolean, @@ -102,6 +132,9 @@ fun GalleryScreen( .windowInsetsPadding(WindowInsets.navigationBars), bots = attachBots.filter { it.showInAttachMenu && it.name.isNotBlank() }, selectedCount = selectedMedia.size, + canCreatePoll = canCreatePoll, + onAttachFileClick = onAttachFileClick, + onCreatePollClick = onCreatePollClick, onSendSelected = { onMediaSelected(selectedMedia.toList()) }, onAttachBotClick = onAttachBotClick ) diff --git a/presentation/src/main/java/org/monogram/presentation/features/gallery/components/AttachBotsSection.kt b/presentation/src/main/java/org/monogram/presentation/features/gallery/components/AttachBotsSection.kt index b4901509..b4a66605 100644 --- a/presentation/src/main/java/org/monogram/presentation/features/gallery/components/AttachBotsSection.kt +++ b/presentation/src/main/java/org/monogram/presentation/features/gallery/components/AttachBotsSection.kt @@ -1,19 +1,41 @@ package org.monogram.presentation.features.gallery.components -import androidx.compose.animation.* +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.animation.animateContentSize import androidx.compose.animation.core.tween +import androidx.compose.animation.fadeIn +import androidx.compose.animation.fadeOut +import androidx.compose.animation.slideInVertically +import androidx.compose.animation.slideOutVertically import androidx.compose.foundation.background import androidx.compose.foundation.border import androidx.compose.foundation.clickable -import androidx.compose.foundation.layout.* +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size import androidx.compose.foundation.lazy.LazyRow import androidx.compose.foundation.lazy.items import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material.icons.Icons import androidx.compose.material.icons.automirrored.filled.Send +import androidx.compose.material.icons.filled.Description import androidx.compose.material.icons.filled.Extension -import androidx.compose.material3.* +import androidx.compose.material.icons.filled.Poll +import androidx.compose.material3.Button +import androidx.compose.material3.ButtonDefaults +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedButton +import androidx.compose.material3.Surface +import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier @@ -33,11 +55,12 @@ fun AttachBotsSection( modifier: Modifier = Modifier, bots: List, selectedCount: Int, + canCreatePoll: Boolean, + onAttachFileClick: () -> Unit, + onCreatePollClick: () -> Unit, onSendSelected: () -> Unit, onAttachBotClick: (AttachMenuBotModel) -> Unit ) { - if (bots.isEmpty() && selectedCount == 0) return - Surface( modifier = modifier, shape = RoundedCornerShape(24.dp), @@ -62,19 +85,28 @@ fun AttachBotsSection( } AnimatedVisibility( - visible = bots.isNotEmpty() && selectedCount == 0, + visible = selectedCount == 0, enter = fadeIn(tween(220)) + slideInVertically(tween(220)) { it / 2 }, exit = fadeOut(tween(140)) ) { - LazyRow( - contentPadding = PaddingValues(bottom = 2.dp), - horizontalArrangement = Arrangement.spacedBy(8.dp) - ) { - items(bots, key = { it.botUserId }) { bot -> - AttachBotTile( - bot = bot, - onClick = { onAttachBotClick(bot) } - ) + Column(verticalArrangement = Arrangement.spacedBy(8.dp)) { + QuickAttachActions( + canCreatePoll = canCreatePoll, + onAttachFileClick = onAttachFileClick, + onCreatePollClick = onCreatePollClick + ) + if (bots.isNotEmpty()) { + LazyRow( + contentPadding = PaddingValues(bottom = 2.dp), + horizontalArrangement = Arrangement.spacedBy(8.dp) + ) { + items(bots, key = { it.botUserId }) { bot -> + AttachBotTile( + bot = bot, + onClick = { onAttachBotClick(bot) } + ) + } + } } } } @@ -82,6 +114,47 @@ fun AttachBotsSection( } } +@Composable +private fun QuickAttachActions( + canCreatePoll: Boolean, + onAttachFileClick: () -> Unit, + onCreatePollClick: () -> Unit +) { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(8.dp) + ) { + val fileButtonModifier = if (canCreatePoll) Modifier.weight(1f) else Modifier.fillMaxWidth() + OutlinedButton( + onClick = onAttachFileClick, + modifier = fileButtonModifier + ) { + Icon( + imageVector = Icons.Filled.Description, + contentDescription = null, + modifier = Modifier.size(16.dp) + ) + Spacer(Modifier.size(8.dp)) + Text(stringResource(R.string.action_attach_file)) + } + + if (canCreatePoll) { + OutlinedButton( + onClick = onCreatePollClick, + modifier = Modifier.weight(1f) + ) { + Icon( + imageVector = Icons.Filled.Poll, + contentDescription = null, + modifier = Modifier.size(16.dp) + ) + Spacer(Modifier.size(8.dp)) + Text(stringResource(R.string.action_create_poll)) + } + } + } +} + @Composable private fun SendSelectedButton( selectedCount: Int, diff --git a/presentation/src/main/java/org/monogram/presentation/features/gallery/components/PollComposerSheet.kt b/presentation/src/main/java/org/monogram/presentation/features/gallery/components/PollComposerSheet.kt new file mode 100644 index 00000000..a154468c --- /dev/null +++ b/presentation/src/main/java/org/monogram/presentation/features/gallery/components/PollComposerSheet.kt @@ -0,0 +1,680 @@ +package org.monogram.presentation.features.gallery.components + +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.animation.animateColorAsState +import androidx.compose.animation.core.tween +import androidx.compose.animation.expandVertically +import androidx.compose.animation.fadeIn +import androidx.compose.animation.fadeOut +import androidx.compose.animation.shrinkVertically +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.foundation.verticalScroll +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.rounded.Bolt +import androidx.compose.material.icons.rounded.ChatBubbleOutline +import androidx.compose.material.icons.rounded.CheckCircle +import androidx.compose.material.icons.rounded.Delete +import androidx.compose.material.icons.rounded.Description +import androidx.compose.material.icons.rounded.Event +import androidx.compose.material.icons.rounded.Info +import androidx.compose.material.icons.rounded.Lock +import androidx.compose.material.icons.rounded.RadioButtonChecked +import androidx.compose.material.icons.rounded.RadioButtonUnchecked +import androidx.compose.material.icons.rounded.RestartAlt +import androidx.compose.material.icons.rounded.Schedule +import androidx.compose.material.icons.rounded.School +import androidx.compose.material.icons.rounded.Shuffle +import androidx.compose.material.icons.rounded.VisibilityOff +import androidx.compose.material3.BottomSheetDefaults +import androidx.compose.material3.Button +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.ModalBottomSheet +import androidx.compose.material3.OutlinedButton +import androidx.compose.material3.Surface +import androidx.compose.material3.Switch +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.material3.rememberModalBottomSheetState +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateListOf +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.input.KeyboardType +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import org.monogram.domain.models.PollDraft +import org.monogram.presentation.R +import org.monogram.presentation.core.ui.ItemPosition +import org.monogram.presentation.features.chats.chatList.components.SettingsTextField +import org.monogram.presentation.features.chats.currentChat.components.inputbar.ScheduleDatePickerDialog +import org.monogram.presentation.features.chats.currentChat.components.inputbar.ScheduleTimePickerDialog +import org.monogram.presentation.features.chats.currentChat.components.inputbar.buildScheduledDateEpochSeconds +import java.text.DateFormat +import java.util.Calendar +import java.util.Date +import java.util.Locale + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun PollComposerSheet( + onDismiss: () -> Unit, + onCreatePoll: (PollDraft) -> Unit +) { + var question by remember { mutableStateOf("") } + val options = remember { mutableStateListOf("", "") } + var description by remember { mutableStateOf("") } + var isAnonymous by remember { mutableStateOf(true) } + var allowsMultipleAnswers by remember { mutableStateOf(false) } + var allowsRevoting by remember { mutableStateOf(true) } + var shuffleOptions by remember { mutableStateOf(false) } + var hideResultsUntilCloses by remember { mutableStateOf(false) } + var openPeriodText by remember { mutableStateOf("") } + var closeDateEpoch by remember { mutableStateOf(null) } + var isClosed by remember { mutableStateOf(false) } + var isQuiz by remember { mutableStateOf(false) } + var explanation by remember { mutableStateOf("") } + val correctOptionIds = remember { mutableStateListOf() } + + var showCloseDatePicker by remember { mutableStateOf(false) } + var showCloseTimePicker by remember { mutableStateOf(false) } + var pendingCloseDateMillis by remember { mutableStateOf(null) } + + val preparedOptions = options.map { it.trim() }.filter { it.isNotEmpty() } + val openPeriod = openPeriodText.toIntOrNull()?.coerceAtLeast(0) ?: 0 + val closeDate = closeDateEpoch ?: 0 + val hasClosingLimit = openPeriod > 0 || closeDate > 0 + val hasValidCorrectSelections = if (!isQuiz) { + true + } else if (allowsMultipleAnswers) { + correctOptionIds.any { it in preparedOptions.indices } + } else { + correctOptionIds.firstOrNull() in preparedOptions.indices + } + val canSubmit = question.trim().isNotEmpty() && + preparedOptions.size >= 2 && + hasValidCorrectSelections + + LaunchedEffect(isQuiz, allowsMultipleAnswers, preparedOptions.size, hasClosingLimit) { + val validIds = correctOptionIds.filter { it in preparedOptions.indices } + if (validIds.size != correctOptionIds.size || validIds != correctOptionIds.toList()) { + correctOptionIds.clear() + correctOptionIds.addAll(validIds.distinct()) + } + if (!isQuiz) { + correctOptionIds.clear() + } else if (!allowsMultipleAnswers && correctOptionIds.size > 1) { + val first = correctOptionIds.first() + correctOptionIds.clear() + correctOptionIds.add(first) + } + if (!hasClosingLimit) { + hideResultsUntilCloses = false + } + } + + ModalBottomSheet( + onDismissRequest = onDismiss, + dragHandle = { BottomSheetDefaults.DragHandle() }, + containerColor = MaterialTheme.colorScheme.background, + contentColor = MaterialTheme.colorScheme.onSurface, + shape = RoundedCornerShape(topStart = 24.dp, topEnd = 24.dp), + sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true) + ) { + Column( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp) + .padding(bottom = 12.dp) + .verticalScroll(rememberScrollState()), + verticalArrangement = Arrangement.Top + ) { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 4.dp), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically + ) { + Text( + text = stringResource(R.string.action_create_poll), + style = MaterialTheme.typography.headlineSmall, + fontWeight = FontWeight.Bold + ) + TextButton(onClick = onDismiss) { + Text(stringResource(R.string.action_cancel)) + } + } + Spacer(modifier = Modifier.height(16.dp)) + + SettingsTextField( + value = question, + onValueChange = { question = it }, + placeholder = stringResource(R.string.poll_question_label), + icon = Icons.Rounded.ChatBubbleOutline, + position = ItemPosition.TOP, + itemSpacing = 2.dp, + singleLine = false, + maxLines = 4 + ) + SettingsTextField( + value = description, + onValueChange = { description = it }, + placeholder = stringResource(R.string.poll_description_label), + icon = Icons.Rounded.Description, + position = ItemPosition.BOTTOM, + itemSpacing = 2.dp, + singleLine = false, + maxLines = 3 + ) + + Spacer(modifier = Modifier.height(8.dp)) + PollSectionHeader(stringResource(R.string.poll_options_label)) + options.forEachIndexed { index, value -> + val position = when { + options.size == 1 -> ItemPosition.STANDALONE + index == 0 -> ItemPosition.TOP + index == options.lastIndex -> ItemPosition.BOTTOM + else -> ItemPosition.MIDDLE + } + SettingsTextField( + value = value, + onValueChange = { options[index] = it }, + placeholder = stringResource(R.string.poll_option_label, index + 1), + icon = Icons.Rounded.RadioButtonUnchecked, + position = position, + itemSpacing = 2.dp, + singleLine = true, + trailingIcon = if (options.size > 2) { + { + IconButton(onClick = { options.removeAt(index) }) { + Icon( + imageVector = Icons.Rounded.Delete, + contentDescription = stringResource(R.string.action_remove), + tint = MaterialTheme.colorScheme.error + ) + } + } + } else { + null + } + ) + } + if (options.size < 10) { + TextButton(onClick = { options.add("") }) { + Text(stringResource(R.string.poll_add_option)) + } + } + + Spacer(modifier = Modifier.height(8.dp)) + PollSectionHeader(stringResource(R.string.poll_options_label)) + PollSwitchRow( + title = stringResource(R.string.poll_anonymous), + icon = Icons.Rounded.VisibilityOff, + position = ItemPosition.TOP, + checked = isAnonymous, + onCheckedChange = { isAnonymous = it } + ) + PollSwitchRow( + title = stringResource(R.string.poll_multiple_choice), + icon = Icons.Rounded.CheckCircle, + position = ItemPosition.MIDDLE, + checked = allowsMultipleAnswers, + onCheckedChange = { allowsMultipleAnswers = it } + ) + PollSwitchRow( + title = stringResource(R.string.poll_allows_revoting), + icon = Icons.Rounded.RestartAlt, + position = ItemPosition.MIDDLE, + checked = allowsRevoting, + onCheckedChange = { allowsRevoting = it } + ) + PollSwitchRow( + title = stringResource(R.string.poll_shuffle_options), + icon = Icons.Rounded.Shuffle, + position = ItemPosition.MIDDLE, + checked = shuffleOptions, + onCheckedChange = { shuffleOptions = it } + ) + PollSwitchRow( + title = stringResource(R.string.poll_closed_immediately), + icon = Icons.Rounded.Bolt, + position = ItemPosition.BOTTOM, + checked = isClosed, + onCheckedChange = { isClosed = it } + ) + + Spacer(modifier = Modifier.height(8.dp)) + PollSectionHeader(stringResource(R.string.poll_open_period_seconds)) + SettingsTextField( + value = openPeriodText, + onValueChange = { openPeriodText = it.filter(Char::isDigit) }, + placeholder = stringResource(R.string.poll_open_period_seconds), + icon = Icons.Rounded.Schedule, + position = ItemPosition.STANDALONE, + itemSpacing = 2.dp, + singleLine = true, + keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number) + ) + Spacer(modifier = Modifier.height(6.dp)) + PollDatePickerRow( + icon = Icons.Rounded.Event, + title = stringResource(R.string.poll_close_date_epoch), + value = closeDateEpoch?.let(::formatEpochSeconds) + ?: stringResource(R.string.poll_close_date_not_set), + onPickClick = { showCloseDatePicker = true }, + onClearClick = { closeDateEpoch = null } + ) + AnimatedVisibility( + visible = hasClosingLimit, + enter = fadeIn(animationSpec = tween(180)) + expandVertically( + animationSpec = tween( + 180 + ) + ), + exit = fadeOut(animationSpec = tween(120)) + shrinkVertically( + animationSpec = tween( + 120 + ) + ) + ) { + Column { + Spacer(modifier = Modifier.height(6.dp)) + PollSwitchRow( + title = stringResource(R.string.poll_hide_results_until_close), + icon = Icons.Rounded.Lock, + position = ItemPosition.STANDALONE, + checked = hideResultsUntilCloses, + onCheckedChange = { hideResultsUntilCloses = it } + ) + } + } + + Spacer(modifier = Modifier.height(8.dp)) + PollSectionHeader(stringResource(R.string.poll_mode_quiz)) + PollSwitchRow( + title = stringResource(R.string.poll_mode_quiz), + icon = Icons.Rounded.School, + position = ItemPosition.STANDALONE, + checked = isQuiz, + onCheckedChange = { + isQuiz = it + if (!it) correctOptionIds.clear() + } + ) + AnimatedVisibility( + visible = isQuiz, + enter = fadeIn(animationSpec = tween(200)) + expandVertically( + animationSpec = tween( + 200 + ) + ), + exit = fadeOut(animationSpec = tween(140)) + shrinkVertically( + animationSpec = tween( + 140 + ) + ) + ) { + Column { + Spacer(modifier = Modifier.height(8.dp)) + PollSectionHeader(stringResource(R.string.poll_correct_option_label)) + preparedOptions.forEachIndexed { index, option -> + val position = when { + preparedOptions.size == 1 -> ItemPosition.STANDALONE + index == 0 -> ItemPosition.TOP + index == preparedOptions.lastIndex -> ItemPosition.BOTTOM + else -> ItemPosition.MIDDLE + } + PollCorrectOptionRow( + title = option, + position = position, + checked = correctOptionIds.contains(index), + allowsMultipleAnswers = allowsMultipleAnswers, + onClick = { + if (allowsMultipleAnswers) { + if (correctOptionIds.contains(index)) { + correctOptionIds.remove(index) + } else { + correctOptionIds.add(index) + } + } else { + correctOptionIds.clear() + correctOptionIds.add(index) + } + } + ) + } + Spacer(modifier = Modifier.height(8.dp)) + SettingsTextField( + value = explanation, + onValueChange = { explanation = it }, + placeholder = stringResource(R.string.poll_explanation_label), + icon = Icons.Rounded.Info, + position = ItemPosition.STANDALONE, + itemSpacing = 2.dp, + singleLine = false, + maxLines = 3 + ) + } + } + + Spacer(modifier = Modifier.height(24.dp)) + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(12.dp) + ) { + OutlinedButton( + onClick = onDismiss, + modifier = Modifier + .weight(1f) + .height(56.dp), + shape = RoundedCornerShape(16.dp) + ) { + Text( + text = stringResource(R.string.action_cancel), + fontSize = 16.sp, + fontWeight = FontWeight.Bold + ) + } + + Button( + onClick = { + onCreatePoll( + PollDraft( + question = question.trim(), + options = preparedOptions, + description = description.trim().ifBlank { null }, + isAnonymous = isAnonymous, + allowsMultipleAnswers = allowsMultipleAnswers, + allowsRevoting = allowsRevoting, + shuffleOptions = shuffleOptions, + hideResultsUntilCloses = hideResultsUntilCloses, + openPeriod = openPeriod, + closeDate = closeDate, + isClosed = isClosed, + isQuiz = isQuiz, + correctOptionIds = if (isQuiz) correctOptionIds.toList() else emptyList(), + explanation = explanation.trim().ifBlank { null } + ) + ) + }, + enabled = canSubmit, + modifier = Modifier + .weight(1f) + .height(56.dp), + shape = RoundedCornerShape(16.dp) + ) { + Text( + text = stringResource(R.string.action_create_poll), + fontSize = 16.sp, + fontWeight = FontWeight.Bold + ) + } + } + } + } + + if (showCloseDatePicker) { + ScheduleDatePickerDialog( + onDismiss = { showCloseDatePicker = false }, + onDateSelected = { selectedDateMillis -> + pendingCloseDateMillis = selectedDateMillis + showCloseDatePicker = false + showCloseTimePicker = true + } + ) + } + + if (showCloseTimePicker) { + val defaultTime = remember { + Calendar.getInstance() + .let { now -> now.get(Calendar.HOUR_OF_DAY) to now.get(Calendar.MINUTE) } + } + ScheduleTimePickerDialog( + initialHour = defaultTime.first, + initialMinute = defaultTime.second, + onDismiss = { + showCloseTimePicker = false + pendingCloseDateMillis = null + }, + onConfirm = { hour, minute -> + val selectedDateMillis = pendingCloseDateMillis + pendingCloseDateMillis = null + showCloseTimePicker = false + if (selectedDateMillis != null) { + closeDateEpoch = + buildScheduledDateEpochSeconds(selectedDateMillis, hour, minute) + } + } + ) + } +} + +@Composable +private fun PollSectionHeader(text: String) { + Text( + text = text, + modifier = Modifier.padding(start = 12.dp, bottom = 8.dp, top = 16.dp), + style = MaterialTheme.typography.titleMedium, + color = MaterialTheme.colorScheme.primary, + fontWeight = FontWeight.Bold + ) +} + +@Composable +private fun PollSwitchRow( + title: String, + icon: ImageVector, + position: ItemPosition, + checked: Boolean, + onCheckedChange: (Boolean) -> Unit +) { + val iconTint by animateColorAsState( + targetValue = if (checked) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.onSurfaceVariant, + animationSpec = tween(durationMillis = 180), + label = "pollSwitchIconTint" + ) + val titleColor by animateColorAsState( + targetValue = if (checked) MaterialTheme.colorScheme.onSurface else MaterialTheme.colorScheme.onSurfaceVariant, + animationSpec = tween(durationMillis = 180), + label = "pollSwitchTitleColor" + ) + val cornerRadius = 24.dp + val shape = when (position) { + ItemPosition.TOP -> RoundedCornerShape( + topStart = cornerRadius, + topEnd = cornerRadius, + bottomStart = 4.dp, + bottomEnd = 4.dp + ) + + ItemPosition.MIDDLE -> RoundedCornerShape(4.dp) + ItemPosition.BOTTOM -> RoundedCornerShape( + bottomStart = cornerRadius, + bottomEnd = cornerRadius, + topStart = 4.dp, + topEnd = 4.dp + ) + + ItemPosition.STANDALONE -> RoundedCornerShape(cornerRadius) + } + Surface( + color = MaterialTheme.colorScheme.surfaceContainer, + shape = shape, + modifier = Modifier.fillMaxWidth() + ) { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp, vertical = 12.dp), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically + ) { + Row( + horizontalArrangement = Arrangement.spacedBy(12.dp), + verticalAlignment = Alignment.CenterVertically + ) { + Icon( + imageVector = icon, + contentDescription = null, + tint = iconTint + ) + Text(title, color = titleColor) + } + Switch( + checked = checked, + onCheckedChange = onCheckedChange + ) + } + } + if (position != ItemPosition.BOTTOM && position != ItemPosition.STANDALONE) { + Spacer(Modifier.height(2.dp)) + } +} + +@Composable +private fun PollCorrectOptionRow( + title: String, + position: ItemPosition, + checked: Boolean, + allowsMultipleAnswers: Boolean, + onClick: () -> Unit +) { + val cornerRadius = 24.dp + val shape = when (position) { + ItemPosition.TOP -> RoundedCornerShape( + topStart = cornerRadius, + topEnd = cornerRadius, + bottomStart = 4.dp, + bottomEnd = 4.dp + ) + + ItemPosition.MIDDLE -> RoundedCornerShape(4.dp) + ItemPosition.BOTTOM -> RoundedCornerShape( + bottomStart = cornerRadius, + bottomEnd = cornerRadius, + topStart = 4.dp, + topEnd = 4.dp + ) + + ItemPosition.STANDALONE -> RoundedCornerShape(cornerRadius) + } + val trailingIconTint by animateColorAsState( + targetValue = if (checked) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.onSurfaceVariant.copy( + alpha = 0.7f + ), + animationSpec = tween(durationMillis = 180), + label = "pollCorrectOptionTint" + ) + + Surface( + color = MaterialTheme.colorScheme.surfaceContainer, + shape = shape, + modifier = Modifier + .fillMaxWidth() + .clickable(onClick = onClick) + ) { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp, vertical = 12.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(12.dp) + ) { + Icon( + imageVector = Icons.Rounded.RadioButtonUnchecked, + contentDescription = null, + tint = MaterialTheme.colorScheme.primary + ) + Text( + text = title, + modifier = Modifier.weight(1f), + style = MaterialTheme.typography.bodyLarge + ) + Icon( + imageVector = if (checked) { + if (allowsMultipleAnswers) Icons.Rounded.CheckCircle else Icons.Rounded.RadioButtonChecked + } else { + Icons.Rounded.RadioButtonUnchecked + }, + contentDescription = null, + tint = trailingIconTint + ) + } + } + if (position != ItemPosition.BOTTOM && position != ItemPosition.STANDALONE) { + Spacer(Modifier.height(2.dp)) + } +} + +@Composable +private fun PollDatePickerRow( + icon: ImageVector, + title: String, + value: String, + onPickClick: () -> Unit, + onClearClick: () -> Unit +) { + Surface( + color = MaterialTheme.colorScheme.surfaceContainer, + shape = RoundedCornerShape(24.dp), + modifier = Modifier.fillMaxWidth() + ) { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp, vertical = 12.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(12.dp) + ) { + Icon( + imageVector = icon, + contentDescription = null, + tint = MaterialTheme.colorScheme.primary + ) + Column(modifier = Modifier.weight(1f)) { + Text( + title, + style = MaterialTheme.typography.labelMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + Text(value, style = MaterialTheme.typography.bodyMedium) + } + TextButton(onClick = onPickClick) { + Text(stringResource(R.string.action_pick)) + } + TextButton(onClick = onClearClick) { + Text(stringResource(R.string.action_clear)) + } + } + } +} + +private fun formatEpochSeconds(epochSeconds: Int): String { + val formatter = DateFormat.getDateTimeInstance( + DateFormat.MEDIUM, + DateFormat.SHORT, + Locale.getDefault() + ) + return formatter.format(Date(epochSeconds.toLong() * 1000L)) +} diff --git a/presentation/src/main/res/values/string.xml b/presentation/src/main/res/values/string.xml index f2c548a6..8c368b3b 100644 --- a/presentation/src/main/res/values/string.xml +++ b/presentation/src/main/res/values/string.xml @@ -1085,6 +1085,9 @@ Cancel edit Attach %1$d items Send media + File + %1$d files attached + Create poll Cancel Copy Paste @@ -1113,6 +1116,22 @@ Only selected photos and videos are visible. Attachments Other sources + Question + Options + Option %1$d + Add option + Description (optional) + Quiz mode + Correct option number + Explanation (optional) + Allow revoting + Shuffle options + Hide results until closed + Send as closed + Open period (seconds) + Close date (epoch seconds) + Not set + Pick All Photos Videos From 08e578e820cbd570b1dde6835061e4ad3321a27f Mon Sep 17 00:00:00 2001 From: Artur Skubei <41114720+gdlbo@users.noreply.github.com> Date: Tue, 14 Apr 2026 23:22:25 +0300 Subject: [PATCH 2/5] refactor PollComposerSheet and implement poll option reordering - replace `ModalBottomSheet` with a custom `Dialog` implementation to provide better control over drag-to-dismiss behavior and layout - implement drag-and-drop reordering for poll options using `detectDragGesturesAfterLongPress` and `graphicsLayer` transformations - add `PollOptionInputRow` component to handle individual option inputs with integrated drag handles and removal actions - ensure `correctOptionIds` are automatically remapped when poll options are moved to maintain quiz integrity - enhance the UI with smoother animations for vertical dragging and dismissal - refine `poll_multiple_choice` string resource by removing the leading bullet point separator --- .../gallery/components/PollComposerSheet.kt | 325 +++++++++++++++--- presentation/src/main/res/values/string.xml | 2 +- 2 files changed, 282 insertions(+), 45 deletions(-) diff --git a/presentation/src/main/java/org/monogram/presentation/features/gallery/components/PollComposerSheet.kt b/presentation/src/main/java/org/monogram/presentation/features/gallery/components/PollComposerSheet.kt index a154468c..2634a09c 100644 --- a/presentation/src/main/java/org/monogram/presentation/features/gallery/components/PollComposerSheet.kt +++ b/presentation/src/main/java/org/monogram/presentation/features/gallery/components/PollComposerSheet.kt @@ -2,19 +2,36 @@ package org.monogram.presentation.features.gallery.components import androidx.compose.animation.AnimatedVisibility import androidx.compose.animation.animateColorAsState +import androidx.compose.animation.core.animate +import androidx.compose.animation.core.spring import androidx.compose.animation.core.tween import androidx.compose.animation.expandVertically import androidx.compose.animation.fadeIn import androidx.compose.animation.fadeOut import androidx.compose.animation.shrinkVertically +import androidx.compose.foundation.background import androidx.compose.foundation.clickable +import androidx.compose.foundation.gestures.Orientation +import androidx.compose.foundation.gestures.detectDragGesturesAfterLongPress +import androidx.compose.foundation.gestures.draggable +import androidx.compose.foundation.gestures.rememberDraggableState +import androidx.compose.foundation.interaction.MutableInteractionSource import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.WindowInsets +import androidx.compose.foundation.layout.asPaddingValues +import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.navigationBars +import androidx.compose.foundation.layout.offset import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.statusBars +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.layout.windowInsetsPadding import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.text.KeyboardOptions @@ -25,6 +42,7 @@ import androidx.compose.material.icons.rounded.ChatBubbleOutline import androidx.compose.material.icons.rounded.CheckCircle import androidx.compose.material.icons.rounded.Delete import androidx.compose.material.icons.rounded.Description +import androidx.compose.material.icons.rounded.DragHandle import androidx.compose.material.icons.rounded.Event import androidx.compose.material.icons.rounded.Info import androidx.compose.material.icons.rounded.Lock @@ -41,28 +59,38 @@ import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.Icon import androidx.compose.material3.IconButton import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.ModalBottomSheet import androidx.compose.material3.OutlinedButton import androidx.compose.material3.Surface import androidx.compose.material3.Switch import androidx.compose.material3.Text import androidx.compose.material3.TextButton -import androidx.compose.material3.rememberModalBottomSheetState +import androidx.compose.material3.TextField +import androidx.compose.material3.TextFieldDefaults import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableFloatStateOf import androidx.compose.runtime.mutableStateListOf import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.graphicsLayer import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.input.pointer.pointerInput +import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.input.KeyboardType +import androidx.compose.ui.unit.IntOffset import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp +import androidx.compose.ui.window.Dialog +import androidx.compose.ui.window.DialogProperties +import kotlinx.coroutines.launch import org.monogram.domain.models.PollDraft import org.monogram.presentation.R import org.monogram.presentation.core.ui.ItemPosition @@ -74,6 +102,7 @@ import java.text.DateFormat import java.util.Calendar import java.util.Date import java.util.Locale +import kotlin.math.roundToInt @OptIn(ExperimentalMaterial3Api::class) @Composable @@ -99,10 +128,19 @@ fun PollComposerSheet( var showCloseDatePicker by remember { mutableStateOf(false) } var showCloseTimePicker by remember { mutableStateOf(false) } var pendingCloseDateMillis by remember { mutableStateOf(null) } + var draggingOptionIndex by remember { mutableStateOf(null) } + var optionDragOffset by remember { mutableFloatStateOf(0f) } + var dismissOffsetY by remember { mutableFloatStateOf(0f) } val preparedOptions = options.map { it.trim() }.filter { it.isNotEmpty() } val openPeriod = openPeriodText.toIntOrNull()?.coerceAtLeast(0) ?: 0 val closeDate = closeDateEpoch ?: 0 + val density = LocalDensity.current + val scope = rememberCoroutineScope() + val optionDragThresholdPx = with(density) { 44.dp.toPx() } + val dismissDistanceThresholdPx = with(density) { 104.dp.toPx() } + val dismissVelocityThresholdPx = with(density) { 360.dp.toPx() } + val statusBarTopPadding = WindowInsets.statusBars.asPaddingValues().calculateTopPadding() val hasClosingLimit = openPeriod > 0 || closeDate > 0 val hasValidCorrectSelections = if (!isQuiz) { true @@ -133,39 +171,112 @@ fun PollComposerSheet( } } - ModalBottomSheet( + val moveOption: (Int, Int) -> Unit = move@{ from, to -> + if (from !in options.indices || to !in options.indices || from == to) return@move + val movedValue = options.removeAt(from) + options.add(to, movedValue) + + if (correctOptionIds.isNotEmpty()) { + val remapped = correctOptionIds.map { selected -> + when { + selected == from -> to + from < to && selected in (from + 1)..to -> selected - 1 + from > to && selected in to until from -> selected + 1 + else -> selected + } + }.distinct() + correctOptionIds.clear() + correctOptionIds.addAll(remapped) + } + } + val dismissDragState = rememberDraggableState { delta -> + dismissOffsetY = (dismissOffsetY + delta).coerceAtLeast(0f) + } + + Dialog( onDismissRequest = onDismiss, - dragHandle = { BottomSheetDefaults.DragHandle() }, - containerColor = MaterialTheme.colorScheme.background, - contentColor = MaterialTheme.colorScheme.onSurface, - shape = RoundedCornerShape(topStart = 24.dp, topEnd = 24.dp), - sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true) + properties = DialogProperties(usePlatformDefaultWidth = false) ) { - Column( - modifier = Modifier - .fillMaxWidth() - .padding(horizontal = 16.dp) - .padding(bottom = 12.dp) - .verticalScroll(rememberScrollState()), - verticalArrangement = Arrangement.Top - ) { - Row( + val scrimInteractionSource = remember { MutableInteractionSource() } + Box(modifier = Modifier.fillMaxSize()) { + Box( modifier = Modifier - .fillMaxWidth() - .padding(horizontal = 4.dp), - horizontalArrangement = Arrangement.SpaceBetween, - verticalAlignment = Alignment.CenterVertically + .fillMaxSize() + .background(MaterialTheme.colorScheme.scrim.copy(alpha = 0.48f)) + .clickable( + interactionSource = scrimInteractionSource, + indication = null, + onClick = onDismiss + ) + ) + + Surface( + modifier = Modifier + .align(Alignment.BottomCenter) + .fillMaxSize() + .padding(top = statusBarTopPadding) + .offset { IntOffset(0, dismissOffsetY.roundToInt()) }, + shape = RoundedCornerShape(topStart = 24.dp, topEnd = 24.dp), + color = MaterialTheme.colorScheme.background, + contentColor = MaterialTheme.colorScheme.onSurface ) { - Text( - text = stringResource(R.string.action_create_poll), - style = MaterialTheme.typography.headlineSmall, - fontWeight = FontWeight.Bold - ) - TextButton(onClick = onDismiss) { - Text(stringResource(R.string.action_cancel)) - } - } - Spacer(modifier = Modifier.height(16.dp)) + Column(modifier = Modifier.fillMaxSize()) { + Column( + modifier = Modifier + .fillMaxWidth() + .draggable( + state = dismissDragState, + orientation = Orientation.Vertical, + onDragStopped = { velocity -> + val shouldDismiss = + dismissOffsetY > dismissDistanceThresholdPx || + velocity > dismissVelocityThresholdPx + if (shouldDismiss) { + onDismiss() + } else { + scope.launch { + animate( + initialValue = dismissOffsetY, + targetValue = 0f, + animationSpec = spring() + ) { value, _ -> dismissOffsetY = value } + } + } + } + ) + .padding(horizontal = 24.dp, vertical = 8.dp), + horizontalAlignment = Alignment.CenterHorizontally + ) { + BottomSheetDefaults.DragHandle() + + Row( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 4.dp), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically + ) { + Text( + text = stringResource(R.string.action_create_poll), + style = MaterialTheme.typography.headlineSmall, + fontWeight = FontWeight.Bold + ) + TextButton(onClick = onDismiss) { + Text(stringResource(R.string.action_cancel)) + } + } + } + + Column( + modifier = Modifier + .fillMaxSize() + .padding(horizontal = 16.dp) + .windowInsetsPadding(WindowInsets.navigationBars) + .padding(bottom = 8.dp) + .verticalScroll(rememberScrollState()), + verticalArrangement = Arrangement.Top + ) { + Spacer(modifier = Modifier.height(8.dp)) SettingsTextField( value = question, @@ -197,24 +308,36 @@ fun PollComposerSheet( index == options.lastIndex -> ItemPosition.BOTTOM else -> ItemPosition.MIDDLE } - SettingsTextField( + PollOptionInputRow( + number = index + 1, value = value, onValueChange = { options[index] = it }, placeholder = stringResource(R.string.poll_option_label, index + 1), - icon = Icons.Rounded.RadioButtonUnchecked, position = position, - itemSpacing = 2.dp, - singleLine = true, - trailingIcon = if (options.size > 2) { - { - IconButton(onClick = { options.removeAt(index) }) { - Icon( - imageVector = Icons.Rounded.Delete, - contentDescription = stringResource(R.string.action_remove), - tint = MaterialTheme.colorScheme.error - ) - } + isDragging = draggingOptionIndex == index, + dragOffset = if (draggingOptionIndex == index) optionDragOffset else 0f, + onDragStart = { + draggingOptionIndex = index + optionDragOffset = 0f + }, + onDragDelta = { deltaY -> + optionDragOffset += deltaY + if (optionDragOffset >= optionDragThresholdPx && index < options.lastIndex) { + moveOption(index, index + 1) + draggingOptionIndex = index + 1 + optionDragOffset = 0f + } else if (optionDragOffset <= -optionDragThresholdPx && index > 0) { + moveOption(index, index - 1) + draggingOptionIndex = index - 1 + optionDragOffset = 0f } + }, + onDragEnd = { + draggingOptionIndex = null + optionDragOffset = 0f + }, + onRemove = if (options.size > 2) { + { options.removeAt(index) } } else { null } @@ -433,6 +556,9 @@ fun PollComposerSheet( } } } + } + } + } if (showCloseDatePicker) { ScheduleDatePickerDialog( @@ -552,6 +678,117 @@ private fun PollSwitchRow( } } +@Composable +private fun PollOptionInputRow( + number: Int, + value: String, + onValueChange: (String) -> Unit, + placeholder: String, + position: ItemPosition, + isDragging: Boolean, + dragOffset: Float, + onDragStart: () -> Unit, + onDragDelta: (Float) -> Unit, + onDragEnd: () -> Unit, + onRemove: (() -> Unit)? +) { + val cornerRadius = 24.dp + val shape = when (position) { + ItemPosition.TOP -> RoundedCornerShape( + topStart = cornerRadius, + topEnd = cornerRadius, + bottomStart = 4.dp, + bottomEnd = 4.dp + ) + + ItemPosition.MIDDLE -> RoundedCornerShape(4.dp) + ItemPosition.BOTTOM -> RoundedCornerShape( + bottomStart = cornerRadius, + bottomEnd = cornerRadius, + topStart = 4.dp, + topEnd = 4.dp + ) + + ItemPosition.STANDALONE -> RoundedCornerShape(cornerRadius) + } + + Surface( + color = MaterialTheme.colorScheme.surfaceContainer, + shape = shape, + modifier = Modifier + .fillMaxWidth() + .graphicsLayer { + translationY = if (isDragging) dragOffset else 0f + } + ) { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 12.dp, vertical = 6.dp), + verticalAlignment = Alignment.CenterVertically + ) { + Text( + text = "$number.", + modifier = Modifier.width(28.dp), + style = MaterialTheme.typography.titleMedium, + color = MaterialTheme.colorScheme.primary, + fontWeight = FontWeight.SemiBold + ) + + TextField( + value = value, + onValueChange = onValueChange, + placeholder = { Text(placeholder) }, + modifier = Modifier.weight(1f), + singleLine = true, + colors = TextFieldDefaults.colors( + focusedContainerColor = Color.Transparent, + unfocusedContainerColor = Color.Transparent, + disabledContainerColor = Color.Transparent, + focusedIndicatorColor = Color.Transparent, + unfocusedIndicatorColor = Color.Transparent, + disabledIndicatorColor = Color.Transparent + ) + ) + + Icon( + imageVector = Icons.Rounded.DragHandle, + contentDescription = null, + tint = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.6f), + modifier = Modifier + .padding(horizontal = 6.dp) + .pointerInput(number) { + detectDragGesturesAfterLongPress( + onDragStart = { + onDragStart() + }, + onDragEnd = { onDragEnd() }, + onDragCancel = { onDragEnd() }, + onDrag = { change, dragAmount -> + change.consume() + onDragDelta(dragAmount.y) + } + ) + } + ) + + if (onRemove != null) { + IconButton(onClick = onRemove) { + Icon( + imageVector = Icons.Rounded.Delete, + contentDescription = stringResource(R.string.action_remove), + tint = MaterialTheme.colorScheme.error + ) + } + } + } + } + + if (position != ItemPosition.BOTTOM && position != ItemPosition.STANDALONE) { + Spacer(Modifier.height(2.dp)) + } +} + @Composable private fun PollCorrectOptionRow( title: String, diff --git a/presentation/src/main/res/values/string.xml b/presentation/src/main/res/values/string.xml index 8c368b3b..e0e4871d 100644 --- a/presentation/src/main/res/values/string.xml +++ b/presentation/src/main/res/values/string.xml @@ -1797,7 +1797,7 @@ Public Quiz Poll - • Multiple Choice + Multiple Choice Retract Vote Close Poll From f51116b8e9995510287bfa6b7576424b1c4f913d Mon Sep 17 00:00:00 2001 From: Artur Skubei <41114720+gdlbo@users.noreply.github.com> Date: Wed, 15 Apr 2026 16:36:18 +0300 Subject: [PATCH 3/5] refactor poll creation and display with enhanced quiz support and localization - update `Poll` and `PollType.Quiz` models to support optional descriptions, multiple correct answers, revoting, and shuffled options - redesign `PollMessageBubble` with a more modern "Hero" header, detailed metadata pills, and improved option cards with animations - implement a comprehensive `PollComposerSheet` featuring drag-and-drop reordering, interactive quiz mode settings, and a deadline picker - add localized strings for poll creation and metadata across multiple languages (pt-rBR, hy, ru-rRU, sk, es, tr, uk, zh-rCN) - enhance `MessageContentMapper` to handle multiple correct option IDs for quiz-type polls - improve poll UI feedback with specialized "Correct" and "Wrong" state indicators and animated content transitions - fix poll closing logic with real-time countdown timers and "results hidden until close" status indicators --- .../mapper/message/MessageContentMapper.kt | 12 +- .../monogram/domain/models/MessageModel.kt | 6 +- .../components/chats/PollMessageBubble.kt | 619 +++++-- .../gallery/components/PollComposerSheet.kt | 1446 ++++++++++------- .../src/main/res/values-es/string.xml | 38 +- .../src/main/res/values-hy/string.xml | 38 +- .../src/main/res/values-pt-rBR/string.xml | 36 + .../src/main/res/values-ru-rRU/string.xml | 36 + .../src/main/res/values-sk/string.xml | 36 + .../src/main/res/values-tr/strings.xml | 36 + .../src/main/res/values-uk/string.xml | 36 + .../src/main/res/values-zh-rCN/string.xml | 36 + presentation/src/main/res/values/string.xml | 48 +- 13 files changed, 1658 insertions(+), 765 deletions(-) diff --git a/data/src/main/java/org/monogram/data/mapper/message/MessageContentMapper.kt b/data/src/main/java/org/monogram/data/mapper/message/MessageContentMapper.kt index 3f4c382a..b23c814e 100644 --- a/data/src/main/java/org/monogram/data/mapper/message/MessageContentMapper.kt +++ b/data/src/main/java/org/monogram/data/mapper/message/MessageContentMapper.kt @@ -8,7 +8,11 @@ import org.monogram.data.mapper.CustomEmojiLoader import org.monogram.data.mapper.TdFileHelper import org.monogram.data.mapper.WebPageMapper import org.monogram.data.mapper.toMessageEntityOrNull -import org.monogram.domain.models.* +import org.monogram.domain.models.MessageContent +import org.monogram.domain.models.MessageEntity +import org.monogram.domain.models.PollOption +import org.monogram.domain.models.PollType +import org.monogram.domain.models.StickerFormat import org.monogram.domain.repository.AppPreferencesProvider internal data class ContentMappingContext( @@ -494,7 +498,7 @@ internal class MessageContentMapper( val pollType = when (val type = poll.type) { is TdApi.PollTypeRegular -> PollType.Regular(poll.allowsMultipleAnswers) is TdApi.PollTypeQuiz -> { - PollType.Quiz(type.correctOptionIds.firstOrNull() ?: -1, type.explanation?.text) + PollType.Quiz(type.correctOptionIds.toList(), type.explanation?.text) } else -> PollType.Regular(poll.allowsMultipleAnswers) @@ -503,6 +507,7 @@ internal class MessageContentMapper( MessageContent.Poll( id = poll.id, question = poll.question.text, + description = null, options = poll.options.map { option -> PollOption( text = option.text.text, @@ -515,6 +520,9 @@ internal class MessageContentMapper( totalVoterCount = poll.totalVoterCount, isClosed = poll.isClosed, isAnonymous = poll.isAnonymous, + allowsRevoting = true, + shuffleOptions = false, + hideResultsUntilCloses = false, type = pollType, openPeriod = poll.openPeriod, closeDate = poll.closeDate diff --git a/domain/src/main/java/org/monogram/domain/models/MessageModel.kt b/domain/src/main/java/org/monogram/domain/models/MessageModel.kt index 117130a4..c9f40971 100644 --- a/domain/src/main/java/org/monogram/domain/models/MessageModel.kt +++ b/domain/src/main/java/org/monogram/domain/models/MessageModel.kt @@ -367,10 +367,14 @@ sealed interface MessageContent { data class Poll( val id: Long, val question: String, + val description: String? = null, val options: List, val totalVoterCount: Int, val isClosed: Boolean, val isAnonymous: Boolean, + val allowsRevoting: Boolean = true, + val shuffleOptions: Boolean = false, + val hideResultsUntilCloses: Boolean = false, val type: PollType, val openPeriod: Int, val closeDate: Int @@ -544,7 +548,7 @@ data class PollOption( sealed interface PollType { data class Regular(val allowMultipleAnswers: Boolean) : PollType - data class Quiz(val correctOptionId: Int, val explanation: String?) : PollType + data class Quiz(val correctOptionIds: List, val explanation: String?) : PollType } @Serializable diff --git a/presentation/src/main/java/org/monogram/presentation/features/chats/currentChat/components/chats/PollMessageBubble.kt b/presentation/src/main/java/org/monogram/presentation/features/chats/currentChat/components/chats/PollMessageBubble.kt index 3d0cd133..df474b9f 100644 --- a/presentation/src/main/java/org/monogram/presentation/features/chats/currentChat/components/chats/PollMessageBubble.kt +++ b/presentation/src/main/java/org/monogram/presentation/features/chats/currentChat/components/chats/PollMessageBubble.kt @@ -1,6 +1,7 @@ package org.monogram.presentation.features.chats.currentChat.components.chats import androidx.compose.animation.AnimatedVisibility +import androidx.compose.animation.animateContentSize import androidx.compose.animation.core.animateFloatAsState import androidx.compose.animation.core.tween import androidx.compose.foundation.BorderStroke @@ -8,28 +9,72 @@ import androidx.compose.foundation.background import androidx.compose.foundation.border import androidx.compose.foundation.clickable import androidx.compose.foundation.gestures.detectTapGestures -import androidx.compose.foundation.layout.* +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.IntrinsicSize +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxHeight +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.layout.widthIn +import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.filled.* +import androidx.compose.material.icons.filled.Cancel +import androidx.compose.material.icons.filled.Check +import androidx.compose.material.icons.filled.CheckCircle +import androidx.compose.material.icons.filled.DoneAll +import androidx.compose.material.icons.filled.Error +import androidx.compose.material.icons.filled.Lightbulb +import androidx.compose.material.icons.filled.MoreVert +import androidx.compose.material.icons.filled.Schedule +import androidx.compose.material.icons.filled.Stop import androidx.compose.material.icons.outlined.RadioButtonUnchecked import androidx.compose.material.icons.rounded.CheckBoxOutlineBlank -import androidx.compose.material3.* -import androidx.compose.runtime.* +import androidx.compose.material.icons.rounded.Event +import androidx.compose.material.icons.rounded.HowToVote +import androidx.compose.material.icons.rounded.Lock +import androidx.compose.material3.DropdownMenu +import androidx.compose.material3.DropdownMenuItem +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableIntStateOf +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip import androidx.compose.ui.geometry.Offset import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.graphicsLayer import androidx.compose.ui.input.pointer.pointerInput import androidx.compose.ui.res.pluralStringResource import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.DpOffset import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp +import kotlinx.coroutines.delay import org.koin.compose.koinInject -import org.monogram.domain.models.* +import org.monogram.domain.models.MessageContent +import org.monogram.domain.models.MessageModel +import org.monogram.domain.models.MessageSendingState +import org.monogram.domain.models.PollOption +import org.monogram.domain.models.PollType import org.monogram.presentation.R import org.monogram.presentation.core.util.DateFormatManager @@ -73,16 +118,25 @@ fun PollMessageBubble( if (isOutgoing) MaterialTheme.colorScheme.primaryContainer else MaterialTheme.colorScheme.surfaceContainerHigh val contentColor = if (isOutgoing) MaterialTheme.colorScheme.onPrimaryContainer else MaterialTheme.colorScheme.onSurface + val accentColor = if (isOutgoing) { + MaterialTheme.colorScheme.onPrimaryContainer + } else { + MaterialTheme.colorScheme.primary + } val hasVoted = content.options.any { it.isChosen } val isQuiz = content.type is PollType.Quiz val quizType = content.type as? PollType.Quiz - val isMultiChoice = (content.type as? PollType.Regular)?.allowMultipleAnswers == true + val isMultiChoice = when (val type = content.type) { + is PollType.Regular -> type.allowMultipleAnswers + is PollType.Quiz -> type.correctOptionIds.size > 1 + } + val showResults = content.isClosed || (hasVoted && !content.hideResultsUntilCloses) Column( modifier = modifier .width(IntrinsicSize.Max) - .widthIn(min = 240.dp, max = 320.dp) + .widthIn(min = 240.dp, max = 332.dp) .pointerInput(Unit) { detectTapGestures( onLongPress = { onLongClick(it) } @@ -97,42 +151,45 @@ fun PollMessageBubble( tonalElevation = 1.dp ) { Column( - modifier = Modifier.padding(12.dp) + modifier = Modifier + .padding(12.dp) + .animateContentSize() ) { msg.forwardInfo?.let { ForwardContent(it, isOutgoing, onForwardClick = toProfile) } msg.replyToMsg?.let { ReplyContent(it, isOutgoing) { onReplyClick(it) } } - PollHeader( - question = content.question, - pollType = content.type, + PollHeroHeader( + content = content, fontSize = fontSize, letterSpacing = letterSpacing, hasVoted = hasVoted, - isClosed = content.isClosed, - isAnonymous = content.isAnonymous, isOutgoing = isOutgoing, + accentColor = accentColor, + contentColor = contentColor, onRetractVote = onRetractVote, onClosePoll = onClosePoll ) - Spacer(modifier = Modifier.height(12.dp)) + Spacer(modifier = Modifier.height(10.dp)) Column(verticalArrangement = Arrangement.spacedBy(8.dp)) { content.options.forEachIndexed { index, option -> - val isCorrect = isQuiz && quizType?.correctOptionId == index - val isWrong = isQuiz && option.isChosen && quizType?.correctOptionId != index - + val isCorrect = + isQuiz && (quizType?.correctOptionIds?.contains(index) == true) + val isWrong = isQuiz && option.isChosen && !isCorrect val canVote = !content.isClosed && !hasVoted - PollOptionItem( + PollOptionCard( + index = index, option = option, isMultiChoice = isMultiChoice, isQuiz = isQuiz, isCorrect = isCorrect, isWrong = isWrong, - showResults = hasVoted || content.isClosed, + showResults = showResults, isAnonymous = content.isAnonymous, canVote = canVote, + accentColor = accentColor, onClick = { onOptionClick(index) }, onShowVoters = { onShowVoters(index) } ) @@ -142,16 +199,14 @@ fun PollMessageBubble( AnimatedVisibility( visible = isQuiz && hasVoted && !quizType?.explanation.isNullOrBlank() ) { - QuizExplanationBox( - text = quizType?.explanation ?: "", - containerColor = contentColor.copy(alpha = 0.05f), - textColor = contentColor + QuizExplanationCard( + text = quizType?.explanation.orEmpty(), + accentColor = accentColor, + contentColor = contentColor ) } - Spacer(modifier = Modifier.height(8.dp)) - - PollFooter( + PollFooterBar( totalVotes = content.totalVoterCount, date = msg.date, isOutgoing = isOutgoing, @@ -171,93 +226,149 @@ fun PollMessageBubble( } @Composable -private fun PollHeader( - question: String, - pollType: PollType, +private fun PollHeroHeader( + content: MessageContent.Poll, fontSize: Float, letterSpacing: Float, hasVoted: Boolean, - isClosed: Boolean, - isAnonymous: Boolean, isOutgoing: Boolean, + accentColor: Color, + contentColor: Color, onRetractVote: () -> Unit, onClosePoll: () -> Unit ) { var showMenu by remember { mutableStateOf(false) } + val dateFormatManager: DateFormatManager = koinInject() + val timeFormat = dateFormatManager.getHourMinuteFormat() + var nowEpochSeconds by remember { mutableIntStateOf((System.currentTimeMillis() / 1000L).toInt()) } + + LaunchedEffect(content.closeDate, content.isClosed) { + if (content.closeDate <= 0 || content.isClosed) return@LaunchedEffect + while (true) { + nowEpochSeconds = (System.currentTimeMillis() / 1000L).toInt() + if (nowEpochSeconds >= content.closeDate) break + delay(1_000L) + } + } + + val remainingSeconds = (content.closeDate - nowEpochSeconds).coerceAtLeast(0) + val isQuiz = content.type is PollType.Quiz + val isMultiChoice = when (val type = content.type) { + is PollType.Regular -> type.allowMultipleAnswers + is PollType.Quiz -> type.correctOptionIds.size > 1 + } - Column { + Column(verticalArrangement = Arrangement.spacedBy(8.dp)) { Row( modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween, verticalAlignment = Alignment.Top ) { - val isQuiz = pollType is PollType.Quiz - val isMultiChoice = (pollType as? PollType.Regular)?.allowMultipleAnswers == true - - val label = if (isClosed) { - stringResource(R.string.poll_final_results) - } else { - buildString { - append(if (isAnonymous) stringResource(R.string.poll_anonymous) else stringResource(R.string.poll_public)) - append(" ") - append(if (isQuiz) stringResource(R.string.poll_type_quiz) else stringResource(R.string.poll_type_poll)) - if (isMultiChoice) append(stringResource(R.string.poll_multiple_choice)) + Row( + horizontalArrangement = Arrangement.spacedBy(6.dp), + verticalAlignment = Alignment.CenterVertically + ) { + PollHeaderPill( + text = if (content.isClosed) { + stringResource(R.string.poll_final_results) + } else if (content.isAnonymous) { + stringResource(R.string.poll_anonymous) + } else { + stringResource(R.string.poll_public) + }, + accentColor = accentColor, + prominent = true + ) + PollHeaderPill( + text = if (isQuiz) { + stringResource(R.string.poll_type_quiz) + } else { + stringResource(R.string.poll_type_poll) + }, + accentColor = accentColor + ) + if (isMultiChoice) { + PollHeaderPill( + text = stringResource(R.string.poll_create_chip_multiple_answers), + accentColor = accentColor + ) } } - Text( - text = label.uppercase(), - style = MaterialTheme.typography.labelSmall, - color = MaterialTheme.colorScheme.primary, - fontWeight = FontWeight.SemiBold, - modifier = Modifier.padding(bottom = 4.dp) - ) - - if (!isClosed && (hasVoted || isOutgoing)) { + if (!content.isClosed && (hasVoted || isOutgoing)) { Box { IconButton( onClick = { showMenu = true }, - modifier = Modifier.size(20.dp) + modifier = Modifier.size(24.dp) ) { Icon( imageVector = Icons.Default.MoreVert, contentDescription = stringResource(R.string.cd_more), - modifier = Modifier.size(16.dp) + modifier = Modifier.size(18.dp) ) } + DropdownMenu( expanded = showMenu, - onDismissRequest = { showMenu = false } + onDismissRequest = { showMenu = false }, + offset = DpOffset(x = 0.dp, y = 8.dp), + shape = RoundedCornerShape(22.dp), + containerColor = Color.Transparent, + tonalElevation = 0.dp, + shadowElevation = 0.dp ) { - if (hasVoted) { - DropdownMenuItem( - text = { Text(stringResource(R.string.poll_retract_vote)) }, - onClick = { - showMenu = false - onRetractVote() - }, - leadingIcon = { - Icon( - imageVector = Icons.Default.Cancel, - contentDescription = null + Surface( + modifier = Modifier.widthIn(min = 220.dp, max = 260.dp), + shape = RoundedCornerShape(22.dp), + color = MaterialTheme.colorScheme.surfaceContainerHigh + ) { + Column(modifier = Modifier.padding(vertical = 8.dp)) { + if (hasVoted) { + DropdownMenuItem( + text = { Text(stringResource(R.string.poll_retract_vote)) }, + onClick = { + showMenu = false + onRetractVote() + }, + leadingIcon = { + Icon( + imageVector = Icons.Default.Cancel, + contentDescription = null, + tint = MaterialTheme.colorScheme.primary + ) + } ) } - ) - } - if (isOutgoing) { - DropdownMenuItem( - text = { Text(stringResource(R.string.poll_close_poll)) }, - onClick = { - showMenu = false - onClosePoll() - }, - leadingIcon = { - Icon( - imageVector = Icons.Default.Stop, - contentDescription = null + if (isOutgoing) { + if (hasVoted) { + HorizontalDivider( + modifier = Modifier.padding(horizontal = 12.dp), + color = MaterialTheme.colorScheme.outlineVariant.copy( + alpha = 0.5f + ) + ) + } + DropdownMenuItem( + text = { + Text( + text = stringResource(R.string.poll_close_poll), + color = MaterialTheme.colorScheme.error + ) + }, + onClick = { + showMenu = false + onClosePoll() + }, + leadingIcon = { + Icon( + imageVector = Icons.Default.Stop, + contentDescription = null, + tint = MaterialTheme.colorScheme.error + ) + } ) } - ) + } } } } @@ -265,19 +376,117 @@ private fun PollHeader( } Text( - text = question, + text = content.question, style = MaterialTheme.typography.titleMedium.copy( fontSize = (fontSize + 2).sp, letterSpacing = letterSpacing.sp, fontWeight = FontWeight.Bold, - lineHeight = (fontSize + 6).sp + lineHeight = (fontSize + 7).sp ) ) + + if (!content.description.isNullOrBlank()) { + Text( + text = content.description.orEmpty(), + style = MaterialTheme.typography.bodySmall.copy(letterSpacing = letterSpacing.sp), + color = contentColor.copy(alpha = 0.78f) + ) + } + + AnimatedVisibility(visible = content.closeDate > 0) { + Surface( + shape = RoundedCornerShape(20.dp), + color = accentColor.copy(alpha = 0.09f), + modifier = Modifier.animateContentSize() + ) { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 12.dp, vertical = 10.dp), + horizontalArrangement = Arrangement.spacedBy(10.dp), + verticalAlignment = Alignment.CenterVertically + ) { + Icon( + imageVector = Icons.Rounded.Event, + contentDescription = null, + tint = accentColor, + modifier = Modifier.size(18.dp) + ) + Column(modifier = Modifier.weight(1f)) { + Text( + text = stringResource( + R.string.poll_meta_closes_at, + formatTime(content.closeDate, timeFormat) + ), + style = MaterialTheme.typography.labelLarge, + fontWeight = FontWeight.SemiBold, + color = contentColor + ) + if (!content.isClosed && remainingSeconds > 0) { + Text( + text = stringResource( + R.string.poll_meta_time_left, + formatRemainingDuration(remainingSeconds) + ), + style = MaterialTheme.typography.bodySmall, + color = contentColor.copy(alpha = 0.7f) + ) + } + } + if (content.hideResultsUntilCloses && !content.isClosed) { + Icon( + imageVector = Icons.Rounded.Lock, + contentDescription = null, + tint = accentColor, + modifier = Modifier.size(18.dp) + ) + } + } + } + } + + val metaLabel = buildList { + if (content.hideResultsUntilCloses && !content.isClosed && content.closeDate <= 0) { + add(stringResource(R.string.poll_meta_results_hidden_until_close)) + } + if (content.allowsRevoting) add(stringResource(R.string.poll_meta_revoting_enabled)) + if (content.shuffleOptions) add(stringResource(R.string.poll_meta_shuffle_enabled)) + }.joinToString(" • ") + + if (metaLabel.isNotBlank()) { + Text( + text = metaLabel, + style = MaterialTheme.typography.labelSmall, + color = contentColor.copy(alpha = 0.68f) + ) + } + } +} +@Composable +private fun PollHeaderPill( + text: String, + accentColor: Color, + prominent: Boolean = false +) { + Surface( + shape = RoundedCornerShape(999.dp), + color = if (prominent) accentColor.copy(alpha = 0.14f) else accentColor.copy(alpha = 0.08f) + ) { + Text( + text = text, + modifier = Modifier.padding(horizontal = 10.dp, vertical = 6.dp), + style = MaterialTheme.typography.labelMedium, + fontWeight = FontWeight.SemiBold, + color = accentColor, + maxLines = 1, + overflow = TextOverflow.Ellipsis + ) } } @Composable -private fun PollOptionItem( +private fun PollOptionCard( + index: Int, option: PollOption, isMultiChoice: Boolean, isQuiz: Boolean, @@ -286,44 +495,56 @@ private fun PollOptionItem( showResults: Boolean, isAnonymous: Boolean, canVote: Boolean, + accentColor: Color, onClick: () -> Unit, onShowVoters: () -> Unit ) { val progress by animateFloatAsState( targetValue = if (showResults) option.votePercentage / 100f else 0f, - animationSpec = tween(durationMillis = 600), label = "progress" + animationSpec = tween(durationMillis = 650), + label = "pollProgress" ) - val baseContentColor = MaterialTheme.colorScheme.onSurface - val primaryColor = MaterialTheme.colorScheme.primary - val errorColor = MaterialTheme.colorScheme.error - val successColor = Color(0xFF4CAF50) - + val successColor = Color(0xFF2E8B57) + val cardScale by animateFloatAsState( + targetValue = if (option.isBeingChosen) 0.985f else 1f, + animationSpec = tween(durationMillis = 180), + label = "pollOptionScale" + ) + val cardAlpha by animateFloatAsState( + targetValue = if (option.isBeingChosen) 0.78f else 1f, + animationSpec = tween(durationMillis = 180), + label = "pollOptionAlpha" + ) val stateColor = when { showResults && isCorrect -> successColor - showResults && isWrong -> errorColor - option.isChosen -> primaryColor + showResults && isWrong -> MaterialTheme.colorScheme.error + option.isChosen || option.isBeingChosen -> accentColor else -> MaterialTheme.colorScheme.outline } - - val shape = RoundedCornerShape(12.dp) - val borderColor = - if (option.isChosen || (showResults && isCorrect)) stateColor else MaterialTheme.colorScheme.outlineVariant.copy( - alpha = 0.5f - ) - val backgroundColor = MaterialTheme.colorScheme.surfaceContainer + val borderColor = when { + option.isBeingChosen -> accentColor + option.isChosen || (showResults && isCorrect) -> stateColor.copy(alpha = 0.55f) + else -> MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.45f) + } + val backgroundColor = if (showResults && (isCorrect || isWrong || option.isChosen)) { + stateColor.copy(alpha = 0.08f) + } else { + MaterialTheme.colorScheme.surface + } + val clickEnabled = canVote || (showResults && !isAnonymous) Box( modifier = Modifier .fillMaxWidth() - .height(IntrinsicSize.Min) - .clip(shape) - .background(backgroundColor) - .border( - border = BorderStroke(if (option.isChosen) 2.dp else 1.dp, borderColor), - shape = shape - ) - .clickable(enabled = canVote || (showResults && !isAnonymous)) { + .clip(RoundedCornerShape(20.dp)) + .graphicsLayer { + scaleX = cardScale + scaleY = cardScale + alpha = cardAlpha + } + .animateContentSize() + .clickable(enabled = clickEnabled) { if (canVote) onClick() else if (showResults && !isAnonymous) onShowVoters() } ) { @@ -332,103 +553,140 @@ private fun PollOptionItem( modifier = Modifier .fillMaxHeight() .fillMaxWidth(progress) - .background(stateColor.copy(alpha = 0.15f)) + .background(stateColor.copy(alpha = 0.12f)) ) } Row( modifier = Modifier - .padding(horizontal = 12.dp, vertical = 10.dp) - .fillMaxWidth(), + .fillMaxWidth() + .border( + BorderStroke(if (option.isChosen) 2.dp else 1.dp, borderColor), + RoundedCornerShape(20.dp) + ) + .background(backgroundColor) + .padding(horizontal = 12.dp, vertical = 10.dp), + horizontalArrangement = Arrangement.spacedBy(12.dp), verticalAlignment = Alignment.CenterVertically ) { - Box(modifier = Modifier.size(24.dp), contentAlignment = Alignment.Center) { - if (showResults) { - if (isCorrect || isWrong || option.isChosen) { + Surface( + shape = CircleShape, + color = stateColor.copy(alpha = 0.12f) + ) { + Box( + modifier = Modifier.size(32.dp), + contentAlignment = Alignment.Center + ) { + if (showResults && (isCorrect || isWrong)) { Icon( - imageVector = when { - isCorrect -> Icons.Default.CheckCircle - isWrong -> Icons.Default.Cancel - else -> Icons.Default.CheckCircle - }, + imageVector = if (isCorrect) Icons.Default.CheckCircle else Icons.Default.Cancel, contentDescription = null, tint = stateColor, - modifier = Modifier.size(20.dp) + modifier = Modifier.size(18.dp) ) } else { Text( - text = "${option.votePercentage}%", - style = MaterialTheme.typography.labelSmall, + text = (index + 1).toString(), + style = MaterialTheme.typography.labelLarge, fontWeight = FontWeight.Bold, - color = baseContentColor.copy(alpha = 0.6f) + color = stateColor ) } - } else { - val unselectedIcon = - if (isMultiChoice) Icons.Rounded.CheckBoxOutlineBlank else Icons.Outlined.RadioButtonUnchecked - Icon( - imageVector = unselectedIcon, - contentDescription = stringResource(R.string.cd_vote), - tint = MaterialTheme.colorScheme.outline, - modifier = Modifier.size(20.dp) - ) } } - Spacer(modifier = Modifier.width(12.dp)) - - Text( - text = option.text, - style = MaterialTheme.typography.bodyMedium, - color = if (showResults && (isCorrect || isWrong)) stateColor else baseContentColor, - maxLines = 3, - overflow = TextOverflow.Ellipsis, - modifier = Modifier.weight(1f) - ) + Column( + modifier = Modifier.weight(1f), + verticalArrangement = Arrangement.spacedBy(2.dp) + ) { + Text( + text = option.text, + style = MaterialTheme.typography.bodyMedium, + color = if (showResults && (isCorrect || isWrong)) stateColor else MaterialTheme.colorScheme.onSurface, + maxLines = 3, + overflow = TextOverflow.Ellipsis + ) + if (showResults) { + Text( + text = pluralStringResource( + R.plurals.poll_votes_count, + option.voterCount, + option.voterCount + ), + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + } - if (showResults) { - Spacer(modifier = Modifier.width(8.dp)) - if (isCorrect || isWrong || option.isChosen) { + Column( + horizontalAlignment = Alignment.End, + verticalArrangement = Arrangement.spacedBy(2.dp) + ) { + if (showResults) { Text( text = "${option.votePercentage}%", - style = MaterialTheme.typography.labelMedium, + style = MaterialTheme.typography.labelLarge, fontWeight = FontWeight.Bold, color = stateColor ) } + Icon( + imageVector = when { + showResults && isQuiz && isCorrect -> Icons.Default.CheckCircle + showResults && isQuiz && isWrong -> Icons.Default.Cancel + option.isChosen -> Icons.Default.Check + isMultiChoice -> Icons.Rounded.CheckBoxOutlineBlank + else -> Icons.Outlined.RadioButtonUnchecked + }, + contentDescription = null, + tint = if (option.isChosen || (showResults && (isCorrect || isWrong))) { + stateColor + } else { + MaterialTheme.colorScheme.onSurfaceVariant + }, + modifier = Modifier.size(18.dp) + ) } } } } @Composable -private fun QuizExplanationBox( +private fun QuizExplanationCard( text: String, - containerColor: Color, - textColor: Color + accentColor: Color, + contentColor: Color ) { - Column(modifier = Modifier.padding(top = 12.dp)) { - Surface( - shape = RoundedCornerShape(8.dp), - color = containerColor + Surface( + modifier = Modifier.padding(top = 10.dp), + shape = RoundedCornerShape(20.dp), + color = accentColor.copy(alpha = 0.08f) + ) { + Row( + modifier = Modifier.padding(horizontal = 12.dp, vertical = 10.dp), + horizontalArrangement = Arrangement.spacedBy(10.dp), + verticalAlignment = Alignment.Top ) { - Row( - modifier = Modifier.padding(8.dp), - verticalAlignment = Alignment.Top - ) { - Icon( - imageVector = Icons.Default.Lightbulb, - contentDescription = stringResource(R.string.cd_explanation), - tint = MaterialTheme.colorScheme.primary, - modifier = Modifier - .size(16.dp) - .padding(top = 2.dp) + Icon( + imageVector = Icons.Default.Lightbulb, + contentDescription = stringResource(R.string.cd_explanation), + tint = accentColor, + modifier = Modifier + .size(18.dp) + .padding(top = 1.dp) + ) + Column(verticalArrangement = Arrangement.spacedBy(2.dp)) { + Text( + text = stringResource(R.string.poll_create_section_quiz), + style = MaterialTheme.typography.labelLarge, + fontWeight = FontWeight.SemiBold, + color = accentColor ) - Spacer(modifier = Modifier.width(8.dp)) Text( text = text, style = MaterialTheme.typography.bodySmall, - color = textColor + color = contentColor ) } } @@ -436,7 +694,7 @@ private fun QuizExplanationBox( } @Composable -private fun PollFooter( +private fun PollFooterBar( totalVotes: Int, date: Int, isOutgoing: Boolean, @@ -445,18 +703,26 @@ private fun PollFooter( contentColor: Color ) { val metaColor = contentColor.copy(alpha = 0.65f) - val dateFormatManager: DateFormatManager = koinInject() val timeFormat = dateFormatManager.getHourMinuteFormat() Row( modifier = Modifier .fillMaxWidth() - .padding(top = 8.dp), + .padding(top = 10.dp), horizontalArrangement = Arrangement.SpaceBetween, verticalAlignment = Alignment.CenterVertically ) { - Row(verticalAlignment = Alignment.CenterVertically) { + Row( + horizontalArrangement = Arrangement.spacedBy(6.dp), + verticalAlignment = Alignment.CenterVertically + ) { + Icon( + imageVector = Icons.Rounded.HowToVote, + contentDescription = null, + modifier = Modifier.size(14.dp), + tint = metaColor + ) Text( text = pluralStringResource(R.plurals.poll_votes_count, totalVotes, totalVotes), style = MaterialTheme.typography.labelSmall, @@ -476,8 +742,7 @@ private fun PollFooter( val (icon, tint) = when (sendingState) { is MessageSendingState.Pending -> Icons.Default.Schedule to metaColor is MessageSendingState.Failed -> Icons.Default.Error to MaterialTheme.colorScheme.error - null -> if (isRead) Icons.Default.DoneAll to MaterialTheme.colorScheme.primary - else Icons.Default.Check to metaColor + null -> if (isRead) Icons.Default.DoneAll to MaterialTheme.colorScheme.primary else Icons.Default.Check to metaColor } Icon( @@ -489,4 +754,16 @@ private fun PollFooter( } } } -} \ No newline at end of file +} + +private fun formatRemainingDuration(totalSeconds: Int): String { + val days = totalSeconds / 86_400 + val hours = (totalSeconds % 86_400) / 3_600 + val minutes = (totalSeconds % 3_600) / 60 + val seconds = totalSeconds % 60 + return when { + days > 0 -> String.format("%dd %02dh", days, hours) + hours > 0 -> String.format("%02d:%02d:%02d", hours, minutes, seconds) + else -> String.format("%02d:%02d", minutes, seconds) + } +} diff --git a/presentation/src/main/java/org/monogram/presentation/features/gallery/components/PollComposerSheet.kt b/presentation/src/main/java/org/monogram/presentation/features/gallery/components/PollComposerSheet.kt index 2634a09c..206b6b09 100644 --- a/presentation/src/main/java/org/monogram/presentation/features/gallery/components/PollComposerSheet.kt +++ b/presentation/src/main/java/org/monogram/presentation/features/gallery/components/PollComposerSheet.kt @@ -1,14 +1,20 @@ package org.monogram.presentation.features.gallery.components +import android.os.Build import androidx.compose.animation.AnimatedVisibility import androidx.compose.animation.animateColorAsState +import androidx.compose.animation.animateContentSize +import androidx.compose.animation.core.Spring import androidx.compose.animation.core.animate +import androidx.compose.animation.core.animateDpAsState +import androidx.compose.animation.core.animateFloatAsState import androidx.compose.animation.core.spring import androidx.compose.animation.core.tween import androidx.compose.animation.expandVertically import androidx.compose.animation.fadeIn import androidx.compose.animation.fadeOut import androidx.compose.animation.shrinkVertically +import androidx.compose.foundation.BorderStroke import androidx.compose.foundation.background import androidx.compose.foundation.clickable import androidx.compose.foundation.gestures.Orientation @@ -20,7 +26,6 @@ import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row -import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.WindowInsets import androidx.compose.foundation.layout.asPaddingValues import androidx.compose.foundation.layout.fillMaxSize @@ -29,44 +34,43 @@ import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.navigationBars import androidx.compose.foundation.layout.offset import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.statusBars -import androidx.compose.foundation.layout.width import androidx.compose.foundation.layout.windowInsetsPadding import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.shape.RoundedCornerShape -import androidx.compose.foundation.text.KeyboardOptions import androidx.compose.foundation.verticalScroll import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.rounded.Bolt import androidx.compose.material.icons.rounded.ChatBubbleOutline import androidx.compose.material.icons.rounded.CheckCircle +import androidx.compose.material.icons.rounded.Close import androidx.compose.material.icons.rounded.Delete import androidx.compose.material.icons.rounded.Description import androidx.compose.material.icons.rounded.DragHandle import androidx.compose.material.icons.rounded.Event -import androidx.compose.material.icons.rounded.Info import androidx.compose.material.icons.rounded.Lock import androidx.compose.material.icons.rounded.RadioButtonChecked import androidx.compose.material.icons.rounded.RadioButtonUnchecked import androidx.compose.material.icons.rounded.RestartAlt -import androidx.compose.material.icons.rounded.Schedule import androidx.compose.material.icons.rounded.School import androidx.compose.material.icons.rounded.Shuffle import androidx.compose.material.icons.rounded.VisibilityOff import androidx.compose.material3.BottomSheetDefaults import androidx.compose.material3.Button import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.Icon import androidx.compose.material3.IconButton import androidx.compose.material3.MaterialTheme import androidx.compose.material3.OutlinedButton import androidx.compose.material3.Surface -import androidx.compose.material3.Switch import androidx.compose.material3.Text import androidx.compose.material3.TextButton import androidx.compose.material3.TextField import androidx.compose.material3.TextFieldDefaults import androidx.compose.runtime.Composable +import androidx.compose.runtime.DisposableEffect import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableFloatStateOf @@ -77,23 +81,35 @@ import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.shadow import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.graphicsLayer +import androidx.compose.ui.graphics.luminance +import androidx.compose.ui.graphics.toArgb import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.hapticfeedback.HapticFeedbackType import androidx.compose.ui.input.pointer.pointerInput +import androidx.compose.ui.layout.onSizeChanged import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.platform.LocalHapticFeedback +import androidx.compose.ui.platform.LocalView import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.font.FontWeight -import androidx.compose.ui.text.input.KeyboardType +import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.IntOffset import androidx.compose.ui.unit.dp -import androidx.compose.ui.unit.sp import androidx.compose.ui.window.Dialog import androidx.compose.ui.window.DialogProperties +import androidx.compose.ui.window.DialogWindowProvider +import androidx.compose.ui.zIndex +import androidx.core.view.WindowCompat import kotlinx.coroutines.launch import org.monogram.domain.models.PollDraft import org.monogram.presentation.R import org.monogram.presentation.core.ui.ItemPosition +import org.monogram.presentation.core.ui.SettingsSwitchTile +import org.monogram.presentation.core.ui.SettingsTile +import org.monogram.presentation.features.chats.chatList.components.SectionHeader import org.monogram.presentation.features.chats.chatList.components.SettingsTextField import org.monogram.presentation.features.chats.currentChat.components.inputbar.ScheduleDatePickerDialog import org.monogram.presentation.features.chats.currentChat.components.inputbar.ScheduleTimePickerDialog @@ -118,9 +134,7 @@ fun PollComposerSheet( var allowsRevoting by remember { mutableStateOf(true) } var shuffleOptions by remember { mutableStateOf(false) } var hideResultsUntilCloses by remember { mutableStateOf(false) } - var openPeriodText by remember { mutableStateOf("") } var closeDateEpoch by remember { mutableStateOf(null) } - var isClosed by remember { mutableStateOf(false) } var isQuiz by remember { mutableStateOf(false) } var explanation by remember { mutableStateOf("") } val correctOptionIds = remember { mutableStateListOf() } @@ -131,33 +145,112 @@ fun PollComposerSheet( var draggingOptionIndex by remember { mutableStateOf(null) } var optionDragOffset by remember { mutableFloatStateOf(0f) } var dismissOffsetY by remember { mutableFloatStateOf(0f) } + var sheetHeightPx by remember { mutableFloatStateOf(0f) } + var isAnimationReady by remember { mutableStateOf(false) } + var isClosing by remember { mutableStateOf(false) } - val preparedOptions = options.map { it.trim() }.filter { it.isNotEmpty() } - val openPeriod = openPeriodText.toIntOrNull()?.coerceAtLeast(0) ?: 0 + val trimmedOptionEntries = options.mapIndexedNotNull { index, value -> + value.trim().takeIf { it.isNotEmpty() }?.let { trimmed -> + index to trimmed + } + } + val sourceToPreparedIndex = trimmedOptionEntries.mapIndexed { preparedIndex, (sourceIndex, _) -> + sourceIndex to preparedIndex + }.toMap() + val preparedOptions = trimmedOptionEntries.map { it.second } + val selectedPreparedCorrectIds = + correctOptionIds.mapNotNull(sourceToPreparedIndex::get).distinct() val closeDate = closeDateEpoch ?: 0 - val density = LocalDensity.current + val hasClosingLimit = closeDate > 0 + val hasValidCorrectSelections = when { + !isQuiz -> true + allowsMultipleAnswers -> selectedPreparedCorrectIds.isNotEmpty() + else -> selectedPreparedCorrectIds.size == 1 + } + val canSubmit = question.trim().isNotEmpty() && + preparedOptions.size >= 2 && + hasValidCorrectSelections + val scope = rememberCoroutineScope() + val density = LocalDensity.current + val statusBarTopPadding = WindowInsets.statusBars.asPaddingValues().calculateTopPadding() val optionDragThresholdPx = with(density) { 44.dp.toPx() } val dismissDistanceThresholdPx = with(density) { 104.dp.toPx() } val dismissVelocityThresholdPx = with(density) { 360.dp.toPx() } - val statusBarTopPadding = WindowInsets.statusBars.asPaddingValues().calculateTopPadding() - val hasClosingLimit = openPeriod > 0 || closeDate > 0 - val hasValidCorrectSelections = if (!isQuiz) { - true - } else if (allowsMultipleAnswers) { - correctOptionIds.any { it in preparedOptions.indices } - } else { - correctOptionIds.firstOrNull() in preparedOptions.indices + val hiddenOffset = sheetHeightPx.takeIf { it > 0f } ?: with(density) { 720.dp.toPx() } + val dismissProgress = (dismissOffsetY / hiddenOffset).coerceIn(0f, 1f) + + fun removeOption(index: Int) { + if (index !in options.indices || options.size <= 2) return + options.removeAt(index) + val remapped = correctOptionIds.mapNotNull { selected -> + when { + selected == index -> null + selected > index -> selected - 1 + else -> selected + } + } + correctOptionIds.clear() + correctOptionIds.addAll(remapped.distinct()) + } + + val moveOption: (Int, Int) -> Unit = move@{ from, to -> + if (from !in options.indices || to !in options.indices || from == to) return@move + val movedValue = options.removeAt(from) + options.add(to, movedValue) + + val remapped = correctOptionIds.map { selected -> + when { + selected == from -> to + from < to && selected in (from + 1)..to -> selected - 1 + from > to && selected in to until from -> selected + 1 + else -> selected + } + } + correctOptionIds.clear() + correctOptionIds.addAll(remapped.distinct()) + } + + fun requestDismiss() { + if (isClosing) return + isClosing = true + scope.launch { + animate( + initialValue = dismissOffsetY, + targetValue = hiddenOffset, + animationSpec = tween(durationMillis = 220) + ) { value, _ -> + dismissOffsetY = value + } + onDismiss() + } + } + + LaunchedEffect(sheetHeightPx) { + if (sheetHeightPx > 0f && !isAnimationReady) { + dismissOffsetY = hiddenOffset + isAnimationReady = true + } } - val canSubmit = question.trim().isNotEmpty() && - preparedOptions.size >= 2 && - hasValidCorrectSelections - LaunchedEffect(isQuiz, allowsMultipleAnswers, preparedOptions.size, hasClosingLimit) { - val validIds = correctOptionIds.filter { it in preparedOptions.indices } - if (validIds.size != correctOptionIds.size || validIds != correctOptionIds.toList()) { + LaunchedEffect(isAnimationReady) { + if (!isAnimationReady) return@LaunchedEffect + animate( + initialValue = dismissOffsetY, + targetValue = 0f, + animationSpec = spring() + ) { value, _ -> + dismissOffsetY = value + } + } + + LaunchedEffect(isQuiz, allowsMultipleAnswers, hasClosingLimit, options.toList()) { + val validIds = correctOptionIds + .filter { index -> options.getOrNull(index)?.trim()?.isNotEmpty() == true } + .distinct() + if (validIds != correctOptionIds.toList()) { correctOptionIds.clear() - correctOptionIds.addAll(validIds.distinct()) + correctOptionIds.addAll(validIds) } if (!isQuiz) { correctOptionIds.clear() @@ -171,42 +264,40 @@ fun PollComposerSheet( } } - val moveOption: (Int, Int) -> Unit = move@{ from, to -> - if (from !in options.indices || to !in options.indices || from == to) return@move - val movedValue = options.removeAt(from) - options.add(to, movedValue) - - if (correctOptionIds.isNotEmpty()) { - val remapped = correctOptionIds.map { selected -> - when { - selected == from -> to - from < to && selected in (from + 1)..to -> selected - 1 - from > to && selected in to until from -> selected + 1 - else -> selected - } - }.distinct() - correctOptionIds.clear() - correctOptionIds.addAll(remapped) - } - } val dismissDragState = rememberDraggableState { delta -> + if (isClosing) return@rememberDraggableState dismissOffsetY = (dismissOffsetY + delta).coerceAtLeast(0f) } + val surfaceScale by animateFloatAsState( + targetValue = if (isAnimationReady && !isClosing) 1f else 0.985f, + animationSpec = spring(), + label = "pollComposerScale" + ) + val surfaceAlpha by animateFloatAsState( + targetValue = if (isAnimationReady && !isClosing) 1f else 0.92f, + animationSpec = tween(durationMillis = 220), + label = "pollComposerAlpha" + ) Dialog( - onDismissRequest = onDismiss, - properties = DialogProperties(usePlatformDefaultWidth = false) + onDismissRequest = ::requestDismiss, + properties = DialogProperties( + usePlatformDefaultWidth = false, + decorFitsSystemWindows = false + ) ) { + PollComposerSystemBars() val scrimInteractionSource = remember { MutableInteractionSource() } + Box(modifier = Modifier.fillMaxSize()) { Box( modifier = Modifier .fillMaxSize() - .background(MaterialTheme.colorScheme.scrim.copy(alpha = 0.48f)) + .background(MaterialTheme.colorScheme.scrim.copy(alpha = 0.48f * (1f - dismissProgress))) .clickable( interactionSource = scrimInteractionSource, indication = null, - onClick = onDismiss + onClick = ::requestDismiss ) ) @@ -215,356 +306,350 @@ fun PollComposerSheet( .align(Alignment.BottomCenter) .fillMaxSize() .padding(top = statusBarTopPadding) - .offset { IntOffset(0, dismissOffsetY.roundToInt()) }, - shape = RoundedCornerShape(topStart = 24.dp, topEnd = 24.dp), + .offset { IntOffset(0, dismissOffsetY.roundToInt()) } + .onSizeChanged { sheetHeightPx = it.height.toFloat() } + .graphicsLayer { + scaleX = surfaceScale + scaleY = surfaceScale + alpha = surfaceAlpha + }, + shape = RoundedCornerShape(topStart = 30.dp, topEnd = 30.dp), color = MaterialTheme.colorScheme.background, contentColor = MaterialTheme.colorScheme.onSurface ) { Column(modifier = Modifier.fillMaxSize()) { - Column( + PollComposerHeader( modifier = Modifier .fillMaxWidth() .draggable( state = dismissDragState, orientation = Orientation.Vertical, onDragStopped = { velocity -> + if (isClosing) return@draggable val shouldDismiss = dismissOffsetY > dismissDistanceThresholdPx || velocity > dismissVelocityThresholdPx if (shouldDismiss) { - onDismiss() + requestDismiss() } else { scope.launch { animate( initialValue = dismissOffsetY, targetValue = 0f, animationSpec = spring() - ) { value, _ -> dismissOffsetY = value } + ) { value, _ -> + dismissOffsetY = value + } } } } ) - .padding(horizontal = 24.dp, vertical = 8.dp), - horizontalAlignment = Alignment.CenterHorizontally + .padding(horizontal = 16.dp, vertical = 8.dp), + onDismiss = ::requestDismiss + ) + + HorizontalDivider( + color = MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.35f) + ) + + Column( + modifier = Modifier + .weight(1f) + .verticalScroll(rememberScrollState()) + .padding(horizontal = 16.dp, vertical = 14.dp), + verticalArrangement = Arrangement.spacedBy(14.dp) ) { - BottomSheetDefaults.DragHandle() - - Row( - modifier = Modifier - .fillMaxWidth() - .padding(horizontal = 4.dp), - horizontalArrangement = Arrangement.SpaceBetween, - verticalAlignment = Alignment.CenterVertically + PollSectionCard( + title = stringResource(R.string.poll_create_section_main) ) { - Text( - text = stringResource(R.string.action_create_poll), - style = MaterialTheme.typography.headlineSmall, - fontWeight = FontWeight.Bold + PollEditorField( + icon = Icons.Rounded.ChatBubbleOutline, + value = question, + onValueChange = { question = it }, + placeholder = stringResource(R.string.poll_create_question_label), + position = ItemPosition.TOP, + minLines = 2, + maxLines = 4, + prominent = true + ) + PollEditorField( + icon = Icons.Rounded.Description, + value = description, + onValueChange = { description = it }, + placeholder = stringResource(R.string.poll_create_description_label), + position = ItemPosition.BOTTOM, + minLines = 2, + maxLines = 3 ) - TextButton(onClick = onDismiss) { - Text(stringResource(R.string.action_cancel)) + } + + PollSectionCard( + title = stringResource(R.string.poll_create_section_options), + subtitle = stringResource(R.string.poll_create_options_hint), + trailing = { + if (options.size < 10) { + TextButton(onClick = { options.add("") }) { + Text(stringResource(R.string.poll_create_add_option)) + } + } + } + ) { + Column(verticalArrangement = Arrangement.spacedBy(4.dp)) { + options.forEachIndexed { index, value -> + val position = when { + options.size == 1 -> ItemPosition.STANDALONE + index == 0 -> ItemPosition.TOP + index == options.lastIndex -> ItemPosition.BOTTOM + else -> ItemPosition.MIDDLE + } + PollOptionInputRow( + number = index + 1, + value = value, + onValueChange = { options[index] = it }, + placeholder = stringResource( + R.string.poll_create_option_label, + index + 1 + ), + position = position, + isDragging = draggingOptionIndex == index, + dragOffset = if (draggingOptionIndex == index) optionDragOffset else 0f, + onDragStart = { + draggingOptionIndex = index + optionDragOffset = 0f + }, + onDragDelta = { deltaY -> + optionDragOffset += deltaY + if (optionDragOffset >= optionDragThresholdPx && index < options.lastIndex) { + moveOption(index, index + 1) + draggingOptionIndex = index + 1 + optionDragOffset = 0f + } else if (optionDragOffset <= -optionDragThresholdPx && index > 0) { + moveOption(index, index - 1) + draggingOptionIndex = index - 1 + optionDragOffset = 0f + } + }, + onDragEnd = { + draggingOptionIndex = null + optionDragOffset = 0f + }, + onRemove = if (options.size > 2) { + { removeOption(index) } + } else { + null + } + ) + } } } - } + PollSectionCard( + title = stringResource(R.string.poll_create_section_settings), + subtitle = stringResource(R.string.poll_create_settings_hint) + ) { + data class SettingsToggleItem( + val icon: ImageVector, + val title: String, + val checked: Boolean, + val onCheckedChange: (Boolean) -> Unit + ) - Column( - modifier = Modifier - .fillMaxSize() - .padding(horizontal = 16.dp) - .windowInsetsPadding(WindowInsets.navigationBars) - .padding(bottom = 8.dp) - .verticalScroll(rememberScrollState()), - verticalArrangement = Arrangement.Top - ) { - Spacer(modifier = Modifier.height(8.dp)) - - SettingsTextField( - value = question, - onValueChange = { question = it }, - placeholder = stringResource(R.string.poll_question_label), - icon = Icons.Rounded.ChatBubbleOutline, - position = ItemPosition.TOP, - itemSpacing = 2.dp, - singleLine = false, - maxLines = 4 - ) - SettingsTextField( - value = description, - onValueChange = { description = it }, - placeholder = stringResource(R.string.poll_description_label), - icon = Icons.Rounded.Description, - position = ItemPosition.BOTTOM, - itemSpacing = 2.dp, - singleLine = false, - maxLines = 3 - ) + val settingsItems = listOf( + SettingsToggleItem( + icon = Icons.Rounded.VisibilityOff, + title = stringResource(R.string.poll_create_anonymous), + checked = isAnonymous, + onCheckedChange = { isAnonymous = it } + ), + SettingsToggleItem( + icon = Icons.Rounded.CheckCircle, + title = stringResource(R.string.poll_create_multiple_answers), + checked = allowsMultipleAnswers, + onCheckedChange = { allowsMultipleAnswers = it } + ), + SettingsToggleItem( + icon = Icons.Rounded.RestartAlt, + title = stringResource(R.string.poll_create_allows_revoting), + checked = allowsRevoting, + onCheckedChange = { allowsRevoting = it } + ), + SettingsToggleItem( + icon = Icons.Rounded.Shuffle, + title = stringResource(R.string.poll_create_shuffle_options), + checked = shuffleOptions, + onCheckedChange = { shuffleOptions = it } + ) + ) - Spacer(modifier = Modifier.height(8.dp)) - PollSectionHeader(stringResource(R.string.poll_options_label)) - options.forEachIndexed { index, value -> - val position = when { - options.size == 1 -> ItemPosition.STANDALONE - index == 0 -> ItemPosition.TOP - index == options.lastIndex -> ItemPosition.BOTTOM - else -> ItemPosition.MIDDLE - } - PollOptionInputRow( - number = index + 1, - value = value, - onValueChange = { options[index] = it }, - placeholder = stringResource(R.string.poll_option_label, index + 1), - position = position, - isDragging = draggingOptionIndex == index, - dragOffset = if (draggingOptionIndex == index) optionDragOffset else 0f, - onDragStart = { - draggingOptionIndex = index - optionDragOffset = 0f - }, - onDragDelta = { deltaY -> - optionDragOffset += deltaY - if (optionDragOffset >= optionDragThresholdPx && index < options.lastIndex) { - moveOption(index, index + 1) - draggingOptionIndex = index + 1 - optionDragOffset = 0f - } else if (optionDragOffset <= -optionDragThresholdPx && index > 0) { - moveOption(index, index - 1) - draggingOptionIndex = index - 1 - optionDragOffset = 0f + settingsItems.forEachIndexed { index, item -> + val position = when { + settingsItems.size == 1 -> ItemPosition.STANDALONE + index == 0 -> ItemPosition.TOP + index == settingsItems.lastIndex -> ItemPosition.BOTTOM + else -> ItemPosition.MIDDLE + } + PollToggleRow( + icon = item.icon, + title = item.title, + checked = item.checked, + onCheckedChange = item.onCheckedChange, + position = position + ) + } } - }, - onDragEnd = { - draggingOptionIndex = null - optionDragOffset = 0f - }, - onRemove = if (options.size > 2) { - { options.removeAt(index) } - } else { - null - } - ) - } - if (options.size < 10) { - TextButton(onClick = { options.add("") }) { - Text(stringResource(R.string.poll_add_option)) - } - } - Spacer(modifier = Modifier.height(8.dp)) - PollSectionHeader(stringResource(R.string.poll_options_label)) - PollSwitchRow( - title = stringResource(R.string.poll_anonymous), - icon = Icons.Rounded.VisibilityOff, - position = ItemPosition.TOP, - checked = isAnonymous, - onCheckedChange = { isAnonymous = it } - ) - PollSwitchRow( - title = stringResource(R.string.poll_multiple_choice), - icon = Icons.Rounded.CheckCircle, - position = ItemPosition.MIDDLE, - checked = allowsMultipleAnswers, - onCheckedChange = { allowsMultipleAnswers = it } - ) - PollSwitchRow( - title = stringResource(R.string.poll_allows_revoting), - icon = Icons.Rounded.RestartAlt, - position = ItemPosition.MIDDLE, - checked = allowsRevoting, - onCheckedChange = { allowsRevoting = it } - ) - PollSwitchRow( - title = stringResource(R.string.poll_shuffle_options), - icon = Icons.Rounded.Shuffle, - position = ItemPosition.MIDDLE, - checked = shuffleOptions, - onCheckedChange = { shuffleOptions = it } - ) - PollSwitchRow( - title = stringResource(R.string.poll_closed_immediately), - icon = Icons.Rounded.Bolt, - position = ItemPosition.BOTTOM, - checked = isClosed, - onCheckedChange = { isClosed = it } - ) - - Spacer(modifier = Modifier.height(8.dp)) - PollSectionHeader(stringResource(R.string.poll_open_period_seconds)) - SettingsTextField( - value = openPeriodText, - onValueChange = { openPeriodText = it.filter(Char::isDigit) }, - placeholder = stringResource(R.string.poll_open_period_seconds), - icon = Icons.Rounded.Schedule, - position = ItemPosition.STANDALONE, - itemSpacing = 2.dp, - singleLine = true, - keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number) - ) - Spacer(modifier = Modifier.height(6.dp)) - PollDatePickerRow( - icon = Icons.Rounded.Event, - title = stringResource(R.string.poll_close_date_epoch), - value = closeDateEpoch?.let(::formatEpochSeconds) - ?: stringResource(R.string.poll_close_date_not_set), - onPickClick = { showCloseDatePicker = true }, - onClearClick = { closeDateEpoch = null } - ) - AnimatedVisibility( - visible = hasClosingLimit, - enter = fadeIn(animationSpec = tween(180)) + expandVertically( - animationSpec = tween( - 180 - ) - ), - exit = fadeOut(animationSpec = tween(120)) + shrinkVertically( - animationSpec = tween( - 120 - ) - ) - ) { - Column { - Spacer(modifier = Modifier.height(6.dp)) - PollSwitchRow( - title = stringResource(R.string.poll_hide_results_until_close), - icon = Icons.Rounded.Lock, - position = ItemPosition.STANDALONE, - checked = hideResultsUntilCloses, - onCheckedChange = { hideResultsUntilCloses = it } - ) - } - } + PollSectionCard( + title = stringResource(R.string.poll_create_section_schedule), + subtitle = stringResource(R.string.poll_create_closing_hint) + ) { + PollDatePickerCard( + value = if (hasClosingLimit) { + formatEpochSeconds(closeDate) + } else { + stringResource(R.string.poll_create_close_date_not_set) + }, + onPickDate = { showCloseDatePicker = true }, + onClearDate = if (hasClosingLimit) { + { + closeDateEpoch = null + hideResultsUntilCloses = false + } + } else { + null + } + ) - Spacer(modifier = Modifier.height(8.dp)) - PollSectionHeader(stringResource(R.string.poll_mode_quiz)) - PollSwitchRow( - title = stringResource(R.string.poll_mode_quiz), - icon = Icons.Rounded.School, - position = ItemPosition.STANDALONE, - checked = isQuiz, - onCheckedChange = { - isQuiz = it - if (!it) correctOptionIds.clear() - } - ) - AnimatedVisibility( - visible = isQuiz, - enter = fadeIn(animationSpec = tween(200)) + expandVertically( - animationSpec = tween( - 200 - ) - ), - exit = fadeOut(animationSpec = tween(140)) + shrinkVertically( - animationSpec = tween( - 140 - ) - ) - ) { - Column { - Spacer(modifier = Modifier.height(8.dp)) - PollSectionHeader(stringResource(R.string.poll_correct_option_label)) - preparedOptions.forEachIndexed { index, option -> - val position = when { - preparedOptions.size == 1 -> ItemPosition.STANDALONE - index == 0 -> ItemPosition.TOP - index == preparedOptions.lastIndex -> ItemPosition.BOTTOM - else -> ItemPosition.MIDDLE + AnimatedVisibility( + visible = hasClosingLimit, + enter = fadeIn(tween(180)) + expandVertically(tween(180)), + exit = fadeOut(tween(140)) + shrinkVertically(tween(140)) + ) { + Column(verticalArrangement = Arrangement.spacedBy(4.dp)) { + PollToggleRow( + icon = Icons.Rounded.Lock, + title = stringResource(R.string.poll_create_hide_results_until_close), + checked = hideResultsUntilCloses, + onCheckedChange = { hideResultsUntilCloses = it }, + position = ItemPosition.STANDALONE + ) + } + } } - PollCorrectOptionRow( - title = option, - position = position, - checked = correctOptionIds.contains(index), - allowsMultipleAnswers = allowsMultipleAnswers, - onClick = { - if (allowsMultipleAnswers) { - if (correctOptionIds.contains(index)) { - correctOptionIds.remove(index) - } else { - correctOptionIds.add(index) + + PollSectionCard( + title = stringResource(R.string.poll_create_section_quiz), + subtitle = stringResource(R.string.poll_create_quiz_hint) + ) { + PollToggleRow( + icon = Icons.Rounded.School, + title = stringResource(R.string.poll_create_quiz_mode), + checked = isQuiz, + onCheckedChange = { isQuiz = it }, + position = ItemPosition.STANDALONE + ) + + AnimatedVisibility( + visible = isQuiz, + enter = fadeIn(tween(200)) + expandVertically(tween(200)), + exit = fadeOut(tween(140)) + shrinkVertically(tween(140)) + ) { + Column( + modifier = Modifier.padding(top = 10.dp), + verticalArrangement = Arrangement.spacedBy(10.dp) + ) { + Text( + text = stringResource(R.string.poll_create_correct_answer_hint), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + + Column(verticalArrangement = Arrangement.spacedBy(4.dp)) { + trimmedOptionEntries.forEachIndexed { preparedIndex, entry -> + val (sourceIndex, text) = entry + val position = when { + trimmedOptionEntries.size == 1 -> ItemPosition.STANDALONE + preparedIndex == 0 -> ItemPosition.TOP + preparedIndex == trimmedOptionEntries.lastIndex -> ItemPosition.BOTTOM + else -> ItemPosition.MIDDLE + } + PollCorrectOptionRow( + number = preparedIndex + 1, + text = text, + checked = correctOptionIds.contains(sourceIndex), + allowMultipleSelection = allowsMultipleAnswers, + position = position, + onClick = { + if (allowsMultipleAnswers) { + if (correctOptionIds.contains(sourceIndex)) { + correctOptionIds.remove(sourceIndex) + } else { + correctOptionIds.add(sourceIndex) + } + } else { + correctOptionIds.clear() + correctOptionIds.add(sourceIndex) + } + } + ) + } } - } else { - correctOptionIds.clear() - correctOptionIds.add(index) + + PollEditorField( + icon = Icons.Rounded.Description, + value = explanation, + onValueChange = { explanation = it }, + placeholder = stringResource(R.string.poll_create_explanation_label), + position = ItemPosition.STANDALONE, + minLines = 2, + maxLines = 4 + ) } } - ) - } - Spacer(modifier = Modifier.height(8.dp)) - SettingsTextField( - value = explanation, - onValueChange = { explanation = it }, - placeholder = stringResource(R.string.poll_explanation_label), - icon = Icons.Rounded.Info, - position = ItemPosition.STANDALONE, - itemSpacing = 2.dp, - singleLine = false, - maxLines = 3 - ) - } - } + } - Spacer(modifier = Modifier.height(24.dp)) - Row( - modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.spacedBy(12.dp) - ) { - OutlinedButton( - onClick = onDismiss, - modifier = Modifier - .weight(1f) - .height(56.dp), - shape = RoundedCornerShape(16.dp) - ) { - Text( - text = stringResource(R.string.action_cancel), - fontSize = 16.sp, - fontWeight = FontWeight.Bold - ) - } + } - Button( - onClick = { - onCreatePoll( - PollDraft( - question = question.trim(), - options = preparedOptions, - description = description.trim().ifBlank { null }, - isAnonymous = isAnonymous, - allowsMultipleAnswers = allowsMultipleAnswers, - allowsRevoting = allowsRevoting, - shuffleOptions = shuffleOptions, - hideResultsUntilCloses = hideResultsUntilCloses, - openPeriod = openPeriod, - closeDate = closeDate, - isClosed = isClosed, - isQuiz = isQuiz, - correctOptionIds = if (isQuiz) correctOptionIds.toList() else emptyList(), - explanation = explanation.trim().ifBlank { null } + PollComposerFooter( + canSubmit = canSubmit, + onCancel = ::requestDismiss, + onSubmit = { + if (!canSubmit) return@PollComposerFooter + onCreatePoll( + PollDraft( + question = question.trim(), + options = preparedOptions, + description = description.trim().ifBlank { null }, + isAnonymous = isAnonymous, + allowsMultipleAnswers = allowsMultipleAnswers, + allowsRevoting = allowsRevoting, + shuffleOptions = shuffleOptions, + hideResultsUntilCloses = hasClosingLimit && hideResultsUntilCloses, + openPeriod = 0, + closeDate = closeDate, + isClosed = false, + isQuiz = isQuiz, + correctOptionIds = if (isQuiz) selectedPreparedCorrectIds else emptyList(), + explanation = explanation.trim().ifBlank { null } + ) ) - ) - }, - enabled = canSubmit, - modifier = Modifier - .weight(1f) - .height(56.dp), - shape = RoundedCornerShape(16.dp) - ) { - Text( - text = stringResource(R.string.action_create_poll), - fontSize = 16.sp, - fontWeight = FontWeight.Bold + }, + modifier = Modifier + .fillMaxWidth() + .windowInsetsPadding(WindowInsets.navigationBars) + .padding(horizontal = 16.dp, vertical = 12.dp) ) } } } } - } - } - } if (showCloseDatePicker) { ScheduleDatePickerDialog( - onDismiss = { showCloseDatePicker = false }, - onDateSelected = { selectedDateMillis -> - pendingCloseDateMillis = selectedDateMillis + onDismiss = { + showCloseDatePicker = false + pendingCloseDateMillis = null + }, + onDateSelected = { selectedMillis -> + pendingCloseDateMillis = selectedMillis showCloseDatePicker = false showCloseTimePicker = true } @@ -572,112 +657,184 @@ fun PollComposerSheet( } if (showCloseTimePicker) { - val defaultTime = remember { - Calendar.getInstance() - .let { now -> now.get(Calendar.HOUR_OF_DAY) to now.get(Calendar.MINUTE) } + val calendar = remember(closeDateEpoch, pendingCloseDateMillis) { + Calendar.getInstance().apply { + timeInMillis = when { + closeDateEpoch != null -> closeDateEpoch!! * 1000L + pendingCloseDateMillis != null -> pendingCloseDateMillis!! + else -> System.currentTimeMillis() + } + } } + ScheduleTimePickerDialog( - initialHour = defaultTime.first, - initialMinute = defaultTime.second, + initialHour = calendar.get(Calendar.HOUR_OF_DAY), + initialMinute = calendar.get(Calendar.MINUTE), onDismiss = { showCloseTimePicker = false pendingCloseDateMillis = null }, onConfirm = { hour, minute -> - val selectedDateMillis = pendingCloseDateMillis - pendingCloseDateMillis = null + val selectedDateMillis = pendingCloseDateMillis ?: System.currentTimeMillis() + closeDateEpoch = buildScheduledDateEpochSeconds(selectedDateMillis, hour, minute) showCloseTimePicker = false - if (selectedDateMillis != null) { - closeDateEpoch = - buildScheduledDateEpochSeconds(selectedDateMillis, hour, minute) - } + pendingCloseDateMillis = null } ) } } @Composable -private fun PollSectionHeader(text: String) { - Text( - text = text, - modifier = Modifier.padding(start = 12.dp, bottom = 8.dp, top = 16.dp), - style = MaterialTheme.typography.titleMedium, - color = MaterialTheme.colorScheme.primary, - fontWeight = FontWeight.Bold - ) +private fun PollComposerSystemBars() { + val view = LocalView.current + val window = (view.parent as? DialogWindowProvider)?.window ?: return + val navigationBarColor = MaterialTheme.colorScheme.surfaceContainerLow + val useDarkNavIcons = navigationBarColor.luminance() > 0.5f + + DisposableEffect(window, navigationBarColor, useDarkNavIcons) { + val insetsController = WindowCompat.getInsetsController(window, window.decorView) + val previousStatusColor = window.statusBarColor + val previousNavigationColor = window.navigationBarColor + val previousLightStatus = insetsController.isAppearanceLightStatusBars + val previousLightNavigation = insetsController.isAppearanceLightNavigationBars + val previousNavContrast = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { + window.isNavigationBarContrastEnforced + } else { + false + } + + WindowCompat.setDecorFitsSystemWindows(window, false) + window.statusBarColor = Color.Transparent.toArgb() + window.navigationBarColor = Color.Transparent.toArgb() + insetsController.isAppearanceLightStatusBars = false + insetsController.isAppearanceLightNavigationBars = useDarkNavIcons + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { + window.isNavigationBarContrastEnforced = false + } + + onDispose { + WindowCompat.setDecorFitsSystemWindows(window, true) + window.statusBarColor = previousStatusColor + window.navigationBarColor = previousNavigationColor + insetsController.isAppearanceLightStatusBars = previousLightStatus + insetsController.isAppearanceLightNavigationBars = previousLightNavigation + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { + window.isNavigationBarContrastEnforced = previousNavContrast + } + } + } } +@OptIn(ExperimentalMaterial3Api::class) @Composable -private fun PollSwitchRow( - title: String, - icon: ImageVector, - position: ItemPosition, - checked: Boolean, - onCheckedChange: (Boolean) -> Unit +private fun PollComposerHeader( + modifier: Modifier = Modifier, + onDismiss: () -> Unit ) { - val iconTint by animateColorAsState( - targetValue = if (checked) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.onSurfaceVariant, - animationSpec = tween(durationMillis = 180), - label = "pollSwitchIconTint" - ) - val titleColor by animateColorAsState( - targetValue = if (checked) MaterialTheme.colorScheme.onSurface else MaterialTheme.colorScheme.onSurfaceVariant, - animationSpec = tween(durationMillis = 180), - label = "pollSwitchTitleColor" - ) - val cornerRadius = 24.dp - val shape = when (position) { - ItemPosition.TOP -> RoundedCornerShape( - topStart = cornerRadius, - topEnd = cornerRadius, - bottomStart = 4.dp, - bottomEnd = 4.dp - ) - - ItemPosition.MIDDLE -> RoundedCornerShape(4.dp) - ItemPosition.BOTTOM -> RoundedCornerShape( - bottomStart = cornerRadius, - bottomEnd = cornerRadius, - topStart = 4.dp, - topEnd = 4.dp + Column( + modifier = modifier, + verticalArrangement = Arrangement.spacedBy(4.dp) + ) { + BottomSheetDefaults.DragHandle( + modifier = Modifier.align(Alignment.CenterHorizontally) ) - ItemPosition.STANDALONE -> RoundedCornerShape(cornerRadius) - } - Surface( - color = MaterialTheme.colorScheme.surfaceContainer, - shape = shape, - modifier = Modifier.fillMaxWidth() - ) { Row( modifier = Modifier .fillMaxWidth() - .padding(horizontal = 16.dp, vertical = 12.dp), + .padding(horizontal = 2.dp), horizontalArrangement = Arrangement.SpaceBetween, verticalAlignment = Alignment.CenterVertically ) { - Row( - horizontalArrangement = Arrangement.spacedBy(12.dp), - verticalAlignment = Alignment.CenterVertically - ) { + Text( + text = stringResource(R.string.poll_create_title), + style = MaterialTheme.typography.headlineSmall, + fontWeight = FontWeight.Bold + ) + IconButton(onClick = onDismiss) { Icon( - imageVector = icon, - contentDescription = null, - tint = iconTint + imageVector = Icons.Rounded.Close, + contentDescription = stringResource(R.string.cancel_button) ) - Text(title, color = titleColor) } - Switch( - checked = checked, - onCheckedChange = onCheckedChange - ) } } - if (position != ItemPosition.BOTTOM && position != ItemPosition.STANDALONE) { - Spacer(Modifier.height(2.dp)) +} + +@Composable +private fun PollSectionCard( + title: String, + subtitle: String? = null, + trailing: @Composable (() -> Unit)? = null, + content: @Composable () -> Unit +) { + Column( + modifier = Modifier.animateContentSize() + ) { + SectionHeader( + text = title, + modifier = Modifier.padding(top = 2.dp) + ) + if (!subtitle.isNullOrBlank()) { + Text( + text = subtitle, + modifier = Modifier.padding(start = 12.dp, bottom = 10.dp), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + Surface( + shape = RoundedCornerShape(24.dp), + color = MaterialTheme.colorScheme.surfaceContainerLow + ) { + Column( + modifier = Modifier.padding(horizontal = 12.dp, vertical = 12.dp) + ) { + if (trailing != null) { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(bottom = 10.dp), + horizontalArrangement = Arrangement.End + ) { + trailing() + } + } + content() + } + } } } +@Composable +private fun PollEditorField( + icon: ImageVector, + value: String, + onValueChange: (String) -> Unit, + placeholder: String, + position: ItemPosition, + minLines: Int, + maxLines: Int, + prominent: Boolean = false +) { + SettingsTextField( + value = value, + onValueChange = onValueChange, + placeholder = placeholder, + icon = icon, + position = position, + singleLine = false, + minLines = minLines, + maxLines = maxLines, + itemSpacing = 2.dp, + modifier = if (prominent) { + Modifier.shadow(0.dp) + } else { + Modifier + } + ) +} + @Composable private fun PollOptionInputRow( number: Int, @@ -690,228 +847,345 @@ private fun PollOptionInputRow( onDragStart: () -> Unit, onDragDelta: (Float) -> Unit, onDragEnd: () -> Unit, - onRemove: (() -> Unit)? + onRemove: (() -> Unit)? = null ) { - val cornerRadius = 24.dp - val shape = when (position) { - ItemPosition.TOP -> RoundedCornerShape( - topStart = cornerRadius, - topEnd = cornerRadius, - bottomStart = 4.dp, - bottomEnd = 4.dp - ) - - ItemPosition.MIDDLE -> RoundedCornerShape(4.dp) - ItemPosition.BOTTOM -> RoundedCornerShape( - bottomStart = cornerRadius, - bottomEnd = cornerRadius, - topStart = 4.dp, - topEnd = 4.dp - ) - - ItemPosition.STANDALONE -> RoundedCornerShape(cornerRadius) - } + val haptic = LocalHapticFeedback.current + val elevation by animateDpAsState( + targetValue = if (isDragging) 8.dp else 0.dp, + animationSpec = spring(stiffness = Spring.StiffnessMediumLow), + label = "optionElevation" + ) + val scale by animateFloatAsState( + targetValue = if (isDragging) 1.02f else 1f, + animationSpec = spring(stiffness = Spring.StiffnessMediumLow), + label = "optionScale" + ) + val containerColor by animateColorAsState( + targetValue = if (isDragging) { + MaterialTheme.colorScheme.surfaceContainerHigh + } else { + MaterialTheme.colorScheme.surfaceContainer + }, + label = "optionColor" + ) + val borderColor by animateColorAsState( + targetValue = if (isDragging) { + MaterialTheme.colorScheme.primary.copy(alpha = 0.35f) + } else { + Color.Transparent + }, + label = "optionBorder" + ) Surface( - color = MaterialTheme.colorScheme.surfaceContainer, - shape = shape, modifier = Modifier .fillMaxWidth() + .zIndex(if (isDragging) 1f else 0f) .graphicsLayer { - translationY = if (isDragging) dragOffset else 0f + translationY = dragOffset + scaleX = scale + scaleY = scale } + .shadow(elevation, shape = pollGroupShape(position)), + shape = pollGroupShape(if (isDragging) ItemPosition.STANDALONE else position), + color = containerColor, + border = BorderStroke(1.dp, borderColor) ) { - Row( + Column( modifier = Modifier .fillMaxWidth() - .padding(horizontal = 12.dp, vertical = 6.dp), - verticalAlignment = Alignment.CenterVertically + .padding(horizontal = 12.dp, vertical = 6.dp) ) { - Text( - text = "$number.", - modifier = Modifier.width(28.dp), - style = MaterialTheme.typography.titleMedium, - color = MaterialTheme.colorScheme.primary, - fontWeight = FontWeight.SemiBold - ) + Row( + modifier = Modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically + ) { + Surface( + shape = CircleShape, + color = MaterialTheme.colorScheme.primary.copy(alpha = 0.12f) + ) { + Box( + modifier = Modifier.size(34.dp), + contentAlignment = Alignment.Center + ) { + Text( + text = number.toString(), + style = MaterialTheme.typography.labelLarge, + color = MaterialTheme.colorScheme.primary, + fontWeight = FontWeight.Bold + ) + } + } - TextField( - value = value, - onValueChange = onValueChange, - placeholder = { Text(placeholder) }, - modifier = Modifier.weight(1f), - singleLine = true, - colors = TextFieldDefaults.colors( - focusedContainerColor = Color.Transparent, - unfocusedContainerColor = Color.Transparent, - disabledContainerColor = Color.Transparent, - focusedIndicatorColor = Color.Transparent, - unfocusedIndicatorColor = Color.Transparent, - disabledIndicatorColor = Color.Transparent + TextField( + value = value, + onValueChange = onValueChange, + modifier = Modifier.weight(1f), + placeholder = { + Text( + text = placeholder, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + }, + textStyle = MaterialTheme.typography.bodyLarge, + singleLine = false, + minLines = 1, + maxLines = 3, + colors = TextFieldDefaults.colors( + focusedContainerColor = Color.Transparent, + unfocusedContainerColor = Color.Transparent, + disabledContainerColor = Color.Transparent, + focusedIndicatorColor = Color.Transparent, + unfocusedIndicatorColor = Color.Transparent, + disabledIndicatorColor = Color.Transparent + ) ) - ) - Icon( - imageVector = Icons.Rounded.DragHandle, - contentDescription = null, - tint = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.6f), - modifier = Modifier - .padding(horizontal = 6.dp) - .pointerInput(number) { - detectDragGesturesAfterLongPress( - onDragStart = { - onDragStart() - }, - onDragEnd = { onDragEnd() }, - onDragCancel = { onDragEnd() }, - onDrag = { change, dragAmount -> - change.consume() - onDragDelta(dragAmount.y) - } + if (onRemove != null) { + IconButton(onClick = onRemove) { + Icon( + imageVector = Icons.Rounded.Delete, + contentDescription = null, + tint = MaterialTheme.colorScheme.onSurfaceVariant ) } - ) + } - if (onRemove != null) { - IconButton(onClick = onRemove) { + Box( + modifier = Modifier + .size(40.dp) + .pointerInput(Unit) { + detectDragGesturesAfterLongPress( + onDragStart = { + haptic.performHapticFeedback(HapticFeedbackType.LongPress) + onDragStart() + }, + onDragCancel = onDragEnd, + onDragEnd = onDragEnd, + onDrag = { change, dragAmount -> + change.consume() + onDragDelta(dragAmount.y) + } + ) + }, + contentAlignment = Alignment.Center + ) { Icon( - imageVector = Icons.Rounded.Delete, - contentDescription = stringResource(R.string.action_remove), - tint = MaterialTheme.colorScheme.error + imageVector = Icons.Rounded.DragHandle, + contentDescription = null, + tint = MaterialTheme.colorScheme.onSurfaceVariant ) } } } } - - if (position != ItemPosition.BOTTOM && position != ItemPosition.STANDALONE) { - Spacer(Modifier.height(2.dp)) - } } @Composable -private fun PollCorrectOptionRow( +private fun PollToggleRow( + icon: ImageVector, title: String, - position: ItemPosition, checked: Boolean, - allowsMultipleAnswers: Boolean, - onClick: () -> Unit + onCheckedChange: (Boolean) -> Unit, + position: ItemPosition ) { - val cornerRadius = 24.dp - val shape = when (position) { - ItemPosition.TOP -> RoundedCornerShape( - topStart = cornerRadius, - topEnd = cornerRadius, - bottomStart = 4.dp, - bottomEnd = 4.dp - ) - - ItemPosition.MIDDLE -> RoundedCornerShape(4.dp) - ItemPosition.BOTTOM -> RoundedCornerShape( - bottomStart = cornerRadius, - bottomEnd = cornerRadius, - topStart = 4.dp, - topEnd = 4.dp - ) - - ItemPosition.STANDALONE -> RoundedCornerShape(cornerRadius) - } - val trailingIconTint by animateColorAsState( - targetValue = if (checked) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.onSurfaceVariant.copy( - alpha = 0.7f - ), - animationSpec = tween(durationMillis = 180), - label = "pollCorrectOptionTint" + SettingsSwitchTile( + icon = icon, + title = title, + checked = checked, + iconColor = MaterialTheme.colorScheme.primary, + position = position, + onCheckedChange = onCheckedChange ) +} +@Composable +private fun PollCorrectOptionRow( + number: Int, + text: String, + checked: Boolean, + allowMultipleSelection: Boolean, + position: ItemPosition, + onClick: () -> Unit +) { Surface( - color = MaterialTheme.colorScheme.surfaceContainer, - shape = shape, modifier = Modifier .fillMaxWidth() - .clickable(onClick = onClick) + .clickable(onClick = onClick), + shape = pollGroupShape(position), + color = if (checked) { + MaterialTheme.colorScheme.primary.copy(alpha = 0.1f) + } else { + MaterialTheme.colorScheme.surfaceContainer + }, + border = BorderStroke( + 1.dp, + if (checked) { + MaterialTheme.colorScheme.primary.copy(alpha = 0.35f) + } else { + MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.4f) + } + ) ) { Row( modifier = Modifier .fillMaxWidth() - .padding(horizontal = 16.dp, vertical = 12.dp), - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(12.dp) + .padding(horizontal = 14.dp, vertical = 12.dp), + horizontalArrangement = Arrangement.spacedBy(12.dp), + verticalAlignment = Alignment.CenterVertically ) { - Icon( - imageVector = Icons.Rounded.RadioButtonUnchecked, - contentDescription = null, - tint = MaterialTheme.colorScheme.primary - ) + Surface( + shape = CircleShape, + color = MaterialTheme.colorScheme.primary.copy(alpha = 0.12f) + ) { + Box( + modifier = Modifier.size(34.dp), + contentAlignment = Alignment.Center + ) { + Text( + text = number.toString(), + style = MaterialTheme.typography.labelLarge, + color = MaterialTheme.colorScheme.primary, + fontWeight = FontWeight.Bold + ) + } + } + Text( - text = title, + text = text, modifier = Modifier.weight(1f), - style = MaterialTheme.typography.bodyLarge + style = MaterialTheme.typography.bodyLarge, + maxLines = 2, + overflow = TextOverflow.Ellipsis ) + Icon( - imageVector = if (checked) { - if (allowsMultipleAnswers) Icons.Rounded.CheckCircle else Icons.Rounded.RadioButtonChecked - } else { - Icons.Rounded.RadioButtonUnchecked + imageVector = when { + allowMultipleSelection && checked -> Icons.Rounded.CheckCircle + allowMultipleSelection -> Icons.Rounded.RadioButtonUnchecked + checked -> Icons.Rounded.RadioButtonChecked + else -> Icons.Rounded.RadioButtonUnchecked }, contentDescription = null, - tint = trailingIconTint + tint = if (checked) { + MaterialTheme.colorScheme.primary + } else { + MaterialTheme.colorScheme.onSurfaceVariant + } ) } } - if (position != ItemPosition.BOTTOM && position != ItemPosition.STANDALONE) { - Spacer(Modifier.height(2.dp)) - } } @Composable -private fun PollDatePickerRow( - icon: ImageVector, - title: String, +private fun PollDatePickerCard( value: String, - onPickClick: () -> Unit, - onClearClick: () -> Unit + onPickDate: () -> Unit, + onClearDate: (() -> Unit)? +) { + SettingsTile( + icon = Icons.Rounded.Event, + title = stringResource(R.string.poll_create_close_date), + subtitle = value, + iconColor = MaterialTheme.colorScheme.primary, + position = ItemPosition.STANDALONE, + onClick = onPickDate, + trailingContent = if (onClearDate != null) { + { + IconButton(onClick = onClearDate) { + Icon( + imageVector = Icons.Rounded.Close, + contentDescription = stringResource(R.string.poll_create_clear_date), + tint = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + } + } else { + null + } + ) +} + +@Composable +private fun PollComposerFooter( + canSubmit: Boolean, + onCancel: () -> Unit, + onSubmit: () -> Unit, + modifier: Modifier = Modifier ) { Surface( - color = MaterialTheme.colorScheme.surfaceContainer, - shape = RoundedCornerShape(24.dp), - modifier = Modifier.fillMaxWidth() + modifier = modifier, + shape = RoundedCornerShape(28.dp), + color = MaterialTheme.colorScheme.surfaceContainerLow ) { - Row( - modifier = Modifier - .fillMaxWidth() - .padding(horizontal = 16.dp, vertical = 12.dp), - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(12.dp) + Column( + modifier = Modifier.padding(horizontal = 14.dp, vertical = 14.dp) ) { - Icon( - imageVector = icon, - contentDescription = null, - tint = MaterialTheme.colorScheme.primary - ) - Column(modifier = Modifier.weight(1f)) { - Text( - title, - style = MaterialTheme.typography.labelMedium, - color = MaterialTheme.colorScheme.onSurfaceVariant - ) - Text(value, style = MaterialTheme.typography.bodyMedium) - } - TextButton(onClick = onPickClick) { - Text(stringResource(R.string.action_pick)) - } - TextButton(onClick = onClearClick) { - Text(stringResource(R.string.action_clear)) + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(12.dp) + ) { + OutlinedButton( + onClick = onCancel, + modifier = Modifier + .weight(1f) + .height(54.dp), + shape = RoundedCornerShape(18.dp) + ) { + Text( + text = stringResource(R.string.cancel_button), + fontWeight = FontWeight.SemiBold + ) + } + Button( + onClick = onSubmit, + enabled = canSubmit, + modifier = Modifier + .weight(1f) + .height(54.dp), + shape = RoundedCornerShape(18.dp) + ) { + Text( + text = stringResource(R.string.action_create_poll), + fontWeight = FontWeight.SemiBold + ) + } } } } } +private fun pollGroupShape(position: ItemPosition): RoundedCornerShape { + val corner = 22.dp + return when (position) { + ItemPosition.TOP -> RoundedCornerShape( + topStart = corner, + topEnd = corner, + bottomStart = 8.dp, + bottomEnd = 8.dp + ) + + ItemPosition.MIDDLE -> RoundedCornerShape(8.dp) + ItemPosition.BOTTOM -> RoundedCornerShape( + topStart = 8.dp, + topEnd = 8.dp, + bottomStart = corner, + bottomEnd = corner + ) + + ItemPosition.STANDALONE -> RoundedCornerShape(corner) + } +} + private fun formatEpochSeconds(epochSeconds: Int): String { - val formatter = DateFormat.getDateTimeInstance( - DateFormat.MEDIUM, - DateFormat.SHORT, - Locale.getDefault() - ) - return formatter.format(Date(epochSeconds.toLong() * 1000L)) + return try { + val formatter = DateFormat.getDateTimeInstance( + DateFormat.MEDIUM, + DateFormat.SHORT, + Locale.getDefault() + ) + formatter.format(Date(epochSeconds * 1000L)) + } catch (_: Exception) { + "" + } } diff --git a/presentation/src/main/res/values-es/string.xml b/presentation/src/main/res/values-es/string.xml index 069c4580..573a87ac 100644 --- a/presentation/src/main/res/values-es/string.xml +++ b/presentation/src/main/res/values-es/string.xml @@ -2034,4 +2034,40 @@ Sonda del centro de datos de Telegram a través del proxy activo Ruta directa Sonda de ruta directa de Telegram - \ No newline at end of file + Pregunta + Opciones + Configuracion de la encuesta + Cierre + Cuestionario + Pregunta + Descripcion (opcional) + Opcion %1$d + Anadir opcion + Votacion anonima + Permitir multiples respuestas + Permitir volver a votar + Mezclar opciones + Ocultar resultados hasta el cierre + Fecha de cierre + Sin establecer + Modo cuestionario + Respuestas correctas + Explicacion (opcional) + Redacta la pregunta, define las respuestas y decide cuando se desbloquean los resultados. + Manten pulsado el control para reordenar opciones. + Elige las reglas de votacion antes de publicar la encuesta. + Establece una fecha limite y, si quieres, oculta los resultados hasta entonces. + Elige las respuestas correctas y anade contexto si hace falta. + Lista para enviar + Anade una pregunta, al menos dos opciones y respuestas validas del cuestionario. + Crear encuesta + Cuestionario + Multiples respuestas + Nuevo voto + Mezcladas + Fecha limite + Resultados ocultos + Borrar fecha + Elegir fecha + Selecciona las respuestas correctas a continuacion. + diff --git a/presentation/src/main/res/values-hy/string.xml b/presentation/src/main/res/values-hy/string.xml index 14ac40a6..de645844 100644 --- a/presentation/src/main/res/values-hy/string.xml +++ b/presentation/src/main/res/values-hy/string.xml @@ -1870,4 +1870,40 @@ Telegram-ի տվյալների կենտրոնի զոնդ ակտիվ proxy-ի միջոցով Ուղիղ երթուղի Telegram-ի ուղիղ երթուղու զոնդ - \ No newline at end of file + Հարց + Տարբերակներ + Հարցման կարգավորումներ + Փակում + Վիկտորինա + Հարց + Նկարագրություն (ըստ ցանկության) + Տարբերակ %1$d + Ավելացնել տարբերակ + Անանուն քվեարկություն + Թույլատրել մի քանի պատասխան + Թույլատրել վերաքվեարկություն + Խառնել տարբերակները + Թաքցնել արդյունքները մինչ փակվելը + Փակման ամսաթիվ + Սահմանված չէ + Վիկտորինայի ռեժիմ + Ճիշտ պատասխաններ + Բացատրություն (ըստ ցանկության) + Գրեք հարցը, կազմեք պատասխանները եւ որոշեք, թե երբ բացվեն արդյունքները։ + Երկար սեղմեք բռնակը՝ տարբերակները տեղափոխելու համար։ + Ընտրեք քվեարկության կանոնները մինչ հրապարակումը։ + Սահմանեք վերջնաժամկետ եւ ցանկության դեպքում թաքցրեք արդյունքները մինչ այդ։ + Ընտրեք ճիշտ պատասխանները եւ ցանկության դեպքում ավելացրեք բացատրություն։ + Պատրաստ է ուղարկման + Ավելացրեք հարց, առնվազն երկու տարբերակ եւ վիկտորինայի վավեր պատասխաններ։ + Ստեղծել հարցում + Վիկտորինա + Մի քանի պատասխան + Վերաքվեարկություն + Խառնված + Վերջնաժամկետ + Թաքցված արդյունքներ + Մաքրել ամսաթիվը + Ընտրել ամսաթիվ + Ստորեւ ընտրեք ճիշտ պատասխանները։ + diff --git a/presentation/src/main/res/values-pt-rBR/string.xml b/presentation/src/main/res/values-pt-rBR/string.xml index 9df85751..c085c8d5 100644 --- a/presentation/src/main/res/values-pt-rBR/string.xml +++ b/presentation/src/main/res/values-pt-rBR/string.xml @@ -2063,4 +2063,40 @@ Sonda do datacenter do Telegram via proxy ativo Rota direta Sonda de rota direta do Telegram + Pergunta + Opcoes + Configuracoes da enquete + Encerramento + Quiz + Pergunta + Descricao (opcional) + Opcao %1$d + Adicionar opcao + Votacao anonima + Permitir multiplas respostas + Permitir votar novamente + Embaralhar opcoes + Ocultar resultados ate encerrar + Data de encerramento + Nao definido + Modo quiz + Respostas corretas + Explicacao (opcional) + Monte a pergunta, organize as respostas e decida quando liberar os resultados. + Pressione e segure a alca para reordenar as opcoes. + Defina as regras de votacao antes de publicar. + Defina um prazo e, se quiser, mantenha os resultados ocultos ate la. + Escolha as respostas corretas e adicione contexto se necessario. + Pronto para enviar + Adicione uma pergunta, pelo menos duas opcoes e respostas validas do quiz. + Criar enquete + Quiz + Multiplas respostas + Novo voto + Embaralhadas + Prazo + Resultados ocultos + Limpar data + Escolher data + Selecione as respostas corretas abaixo. diff --git a/presentation/src/main/res/values-ru-rRU/string.xml b/presentation/src/main/res/values-ru-rRU/string.xml index ba02b38e..be52c08c 100644 --- a/presentation/src/main/res/values-ru-rRU/string.xml +++ b/presentation/src/main/res/values-ru-rRU/string.xml @@ -2052,4 +2052,40 @@ Проверка дата-центра Telegram через активный прокси Прямой маршрут Проверка прямого маршрута Telegram + Вопрос + Варианты + Настройки опроса + Закрытие + Викторина + Вопрос + Описание (необязательно) + Вариант %1$d + Добавить вариант + Анонимное голосование + Разрешить несколько ответов + Разрешить переголосование + Перемешать варианты + Скрыть результаты до закрытия + Дата закрытия + Не задано + Режим викторины + Правильные ответы + Пояснение (необязательно) + Сформулируйте вопрос, настройте ответы и решите, когда открывать результаты. + Удерживайте ручку, чтобы менять порядок вариантов. + Выберите правила голосования перед публикацией опроса. + Укажите дедлайн и при желании скройте итоги до этого времени. + Выберите правильные ответы и добавьте пояснение при необходимости. + Готово к отправке + Добавьте вопрос, минимум два варианта и корректные ответы викторины. + Создать опрос + Викторина + Несколько ответов + Переголосование + Перемешано + Дедлайн + Скрытые результаты + Очистить дату + Выбрать дату + Выберите правильные ответы ниже. diff --git a/presentation/src/main/res/values-sk/string.xml b/presentation/src/main/res/values-sk/string.xml index efda6226..89501037 100644 --- a/presentation/src/main/res/values-sk/string.xml +++ b/presentation/src/main/res/values-sk/string.xml @@ -2182,4 +2182,40 @@ Sonda dátového centra Telegramu cez aktívny proxy Priama trasa Sonda priamej trasy Telegramu + Otazka + Moznosti + Nastavenia ankety + Uzatvorenie + Kviz + Otazka + Popis (volitelne) + Moznost %1$d + Pridat moznost + Anonymne hlasovanie + Povolit viac odpovedi + Povolit opakovane hlasovanie + Premiesat moznosti + Skryt vysledky do uzatvorenia + Datum uzatvorenia + Nenastavene + Rezim kvizu + Spravne odpovede + Vysvetlenie (volitelne) + Vytvorte otazku, upravte odpovede a rozhodnite, kedy sa odomknu vysledky. + Podrzte uchyt a zmente poradie moznosti. + Vyberte pravidla hlasovania pred zverejnenim ankety. + Nastavte termin a pripadne skryte vysledky az do uzatvorenia. + Vyberte spravne odpovede a podla potreby pridajte kontext. + Pripravene na odoslanie + Pridajte otazku, aspon dve moznosti a platne odpovede kvizu. + Vytvorit anketu + Kviz + Viac odpovedi + Opakovane hlasovanie + Premiesane + Termin + Skryte vysledky + Vymazat datum + Vybrat datum + Nizsie vyberte spravne odpovede. diff --git a/presentation/src/main/res/values-tr/strings.xml b/presentation/src/main/res/values-tr/strings.xml index 882ebfba..8e9f74dd 100644 --- a/presentation/src/main/res/values-tr/strings.xml +++ b/presentation/src/main/res/values-tr/strings.xml @@ -2051,4 +2051,40 @@ Etkin proxy üzerinden Telegram veri merkezi yoklaması Doğrudan rota Doğrudan Telegram rota yoklaması + Soru + Secenekler + Anket ayarlari + Kapanis + Test + Soru + Aciklama (istege bagli) + Secenek %1$d + Secenek ekle + Anonim oylama + Birden fazla cevaba izin ver + Yeniden oylamaya izin ver + Secenekleri karistir + Sonuclar kapanisa kadar gizli + Kapanis tarihi + Ayarli degil + Test modu + Dogru cevaplar + Aciklama (istege bagli) + Soruyu olusturun, cevaplari duzenleyin ve sonuclarin ne zaman acilacagini belirleyin. + Secenekleri yeniden siralamak icin tutamacı basili tutun. + Anket yayinlanmadan once oylama kurallarini secin. + Bir son tarih belirleyin ve isterseniz sonuclari o zamana kadar gizleyin. + Dogru cevaplari secin ve gerekirse baglam ekleyin. + Gondermeye hazir + Bir soru, en az iki secenek ve gecerli test cevaplari ekleyin. + Anket olustur + Test + Coklu cevap + Yeniden oylama + Karistirildi + Son tarih + Gizli sonuclar + Tarihi temizle + Tarih sec + Asagidan dogru cevaplari secin. diff --git a/presentation/src/main/res/values-uk/string.xml b/presentation/src/main/res/values-uk/string.xml index a0595b73..f6eeab38 100644 --- a/presentation/src/main/res/values-uk/string.xml +++ b/presentation/src/main/res/values-uk/string.xml @@ -2052,4 +2052,40 @@ Перевірка дата-центру Telegram через активний проксі Прямий маршрут Перевірка прямого маршруту Telegram + Питання + Варіанти + Налаштування опитування + Закриття + Вікторина + Питання + Опис (необов'язково) + Варіант %1$d + Додати варіант + Анонімне голосування + Дозволити кілька відповідей + Дозволити переголосування + Перемішати варіанти + Приховати результати до закриття + Дата закриття + Не встановлено + Режим вікторини + Правильні відповіді + Пояснення (необов'язково) + Сформулюйте питання, налаштуйте варіанти та вирішіть, коли відкрити результати. + Утримуйте ручку, щоб змінювати порядок варіантів. + Оберіть правила голосування перед публікацією опитування. + Встановіть дедлайн і, за потреби, приховайте результати до цього часу. + Оберіть правильні відповіді та додайте пояснення за потреби. + Готово до надсилання + Додайте питання, щонайменше два варіанти та коректні відповіді вікторини. + Створити опитування + Вікторина + Кілька відповідей + Переголосування + Перемішано + Дедлайн + Приховані результати + Очистити дату + Вибрати дату + Оберіть правильні відповіді нижче. diff --git a/presentation/src/main/res/values-zh-rCN/string.xml b/presentation/src/main/res/values-zh-rCN/string.xml index 57711cd0..285f258e 100644 --- a/presentation/src/main/res/values-zh-rCN/string.xml +++ b/presentation/src/main/res/values-zh-rCN/string.xml @@ -2031,4 +2031,40 @@ 通过当前代理探测 Telegram 数据中心 直连路由 探测 Telegram 直连路由 + 问题 + 选项 + 投票设置 + 关闭时间 + 测验 + 问题 + 描述(可选) + 选项 %1$d + 添加选项 + 匿名投票 + 允许多选 + 允许重新投票 + 打乱选项顺序 + 在关闭前隐藏结果 + 关闭日期 + 未设置 + 测验模式 + 正确答案 + 解释(可选) + 编辑问题、整理答案,并决定何时公开结果。 + 长按拖动手柄可调整选项顺序。 + 发布前请先设置投票规则。 + 设置截止时间,并可选择在截止前隐藏结果。 + 选择正确答案,并按需添加说明。 + 可以发送 + 请添加问题、至少两个选项和有效的测验答案。 + 创建投票 + 测验 + 多选 + 可重投 + 已打乱 + 截止时间 + 隐藏结果 + 清除日期 + 选择日期 + 请在下方选择正确答案。 diff --git a/presentation/src/main/res/values/string.xml b/presentation/src/main/res/values/string.xml index e0e4871d..8be65fc4 100644 --- a/presentation/src/main/res/values/string.xml +++ b/presentation/src/main/res/values/string.xml @@ -1128,9 +1128,45 @@ Shuffle options Hide results until closed Send as closed - Open period (seconds) - Close date (epoch seconds) + Open period + Close date Not set + Question + Options + Poll settings + Closing + Quiz + Question + Description (optional) + Option %1$d + Add option + Anonymous voting + Allow multiple answers + Allow revoting + Shuffle options + Hide results until closed + Close date + Not set + Quiz mode + Correct answers + Explanation (optional) + Compose the question, shape the answers, and decide when results unlock. + Long-press the handle to reorder options. + Pick the voting rules before the poll goes live. + Set a deadline and optionally keep totals hidden until then. + Choose the correct answers and add context if needed. + Ready to send + Add a question, at least two options, and valid quiz answers. + Create poll + Quiz + Multiple answers + Revoting + Shuffled + Deadline + Hidden results + Clear date + Pick date + Select the correct answers below. Pick All Photos @@ -1797,9 +1833,15 @@ Public Quiz Poll - Multiple Choice + • Multiple Choice Retract Vote Close Poll + Results hidden until poll closes + Revoting enabled + Options are shuffled + Open period: %1$d + Closes at %1$s + Time left: %1$s %d vote %d votes From 051b615186f557be8a12cc5b80a5f15aa7c08a74 Mon Sep 17 00:00:00 2001 From: Artur Skubei <41114720+gdlbo@users.noreply.github.com> Date: Wed, 15 Apr 2026 16:46:27 +0300 Subject: [PATCH 4/5] convert `chats_selected_format` string to plurals for better localization - convert `chats_selected_format` from a static string to a `` resource across multiple locales (English, Portuguese, Armenian, Russian, Slovak, Spanish, Ukrainian, and Chinese) - update `ChatListContent.kt` to use `pluralStringResource` for the selection counter in the UI - fix character escaping in Ukrainian localization for poll creation labels --- .../features/chats/chatList/ChatListContent.kt | 6 ++++-- presentation/src/main/res/values-es/string.xml | 5 ++++- presentation/src/main/res/values-hy/string.xml | 5 ++++- presentation/src/main/res/values-pt-rBR/string.xml | 5 ++++- presentation/src/main/res/values-ru-rRU/string.xml | 5 ++++- presentation/src/main/res/values-sk/string.xml | 5 ++++- presentation/src/main/res/values-uk/string.xml | 9 ++++++--- presentation/src/main/res/values-zh-rCN/string.xml | 5 ++++- presentation/src/main/res/values/string.xml | 5 ++++- 9 files changed, 38 insertions(+), 12 deletions(-) diff --git a/presentation/src/main/java/org/monogram/presentation/features/chats/chatList/ChatListContent.kt b/presentation/src/main/java/org/monogram/presentation/features/chats/chatList/ChatListContent.kt index 896aa2a8..4a6a9a22 100644 --- a/presentation/src/main/java/org/monogram/presentation/features/chats/chatList/ChatListContent.kt +++ b/presentation/src/main/java/org/monogram/presentation/features/chats/chatList/ChatListContent.kt @@ -98,6 +98,7 @@ import androidx.compose.ui.input.nestedscroll.nestedScroll import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.platform.LocalHapticFeedback +import androidx.compose.ui.res.pluralStringResource import androidx.compose.ui.res.stringResource import androidx.compose.ui.semantics.contentDescription import androidx.compose.ui.semantics.semantics @@ -518,8 +519,9 @@ fun ChatListContent(component: ChatListComponent) { ) if (selectionState.selectedChatIds.isNotEmpty()) { Text( - text = stringResource( - R.string.chats_selected_format, + text = pluralStringResource( + R.plurals.chats_selected_format, + selectionState.selectedChatIds.size, selectionState.selectedChatIds.size ), style = MaterialTheme.typography.bodySmall, diff --git a/presentation/src/main/res/values-es/string.xml b/presentation/src/main/res/values-es/string.xml index 573a87ac..783be987 100644 --- a/presentation/src/main/res/values-es/string.xml +++ b/presentation/src/main/res/values-es/string.xml @@ -109,7 +109,10 @@ Reenviar a... - %1$d chats seleccionados + + %1$d chats seleccionados + %1$d chats seleccionados + Enviar Chats Archivados Nuevo Chat diff --git a/presentation/src/main/res/values-hy/string.xml b/presentation/src/main/res/values-hy/string.xml index de645844..f49bfb10 100644 --- a/presentation/src/main/res/values-hy/string.xml +++ b/presentation/src/main/res/values-hy/string.xml @@ -103,7 +103,10 @@ Վերակայել միացումը Վերահասցեագրել… - Ընտրված է %1$d չատ + + Ընտրված է %1$d չատ + Ընտրված է %1$d չատ + Ուղարկել Արխիվացված չատեր Նոր չատ diff --git a/presentation/src/main/res/values-pt-rBR/string.xml b/presentation/src/main/res/values-pt-rBR/string.xml index c085c8d5..182a9b06 100644 --- a/presentation/src/main/res/values-pt-rBR/string.xml +++ b/presentation/src/main/res/values-pt-rBR/string.xml @@ -110,7 +110,10 @@ Encaminhar para... - %1$d conversas selecionadas + + %1$d conversas selecionadas + %1$d conversas selecionadas + Enviar Chats arquivados Nova conversa diff --git a/presentation/src/main/res/values-ru-rRU/string.xml b/presentation/src/main/res/values-ru-rRU/string.xml index be52c08c..bb74e3d5 100644 --- a/presentation/src/main/res/values-ru-rRU/string.xml +++ b/presentation/src/main/res/values-ru-rRU/string.xml @@ -109,7 +109,10 @@ Переслать… - Выбрано чатов: %1$d + + Выбрано чатов: %1$d + Выбрано чатов: %1$d + Отправить Архив Новый чат diff --git a/presentation/src/main/res/values-sk/string.xml b/presentation/src/main/res/values-sk/string.xml index 89501037..b292c9b6 100644 --- a/presentation/src/main/res/values-sk/string.xml +++ b/presentation/src/main/res/values-sk/string.xml @@ -112,7 +112,10 @@ Preposlať do... - Vybraných %1$d chatov + + Vybraných %1$d chatov + Vybraných %1$d chatov + Odoslať Archivované chaty Nový chat diff --git a/presentation/src/main/res/values-uk/string.xml b/presentation/src/main/res/values-uk/string.xml index f6eeab38..46aa271e 100644 --- a/presentation/src/main/res/values-uk/string.xml +++ b/presentation/src/main/res/values-uk/string.xml @@ -109,7 +109,10 @@ Переслати… - Вибрано чатів: %1$d + + Вибрано чатів: %1$d + Вибрано чатів: %1$d + Надіслати Архів Новий чат @@ -2058,7 +2061,7 @@ Закриття Вікторина Питання - Опис (необов'язково) + Опис (необов\'язково) Варіант %1$d Додати варіант Анонімне голосування @@ -2070,7 +2073,7 @@ Не встановлено Режим вікторини Правильні відповіді - Пояснення (необов'язково) + Пояснення (необов\'язково) Сформулюйте питання, налаштуйте варіанти та вирішіть, коли відкрити результати. Утримуйте ручку, щоб змінювати порядок варіантів. Оберіть правила голосування перед публікацією опитування. diff --git a/presentation/src/main/res/values-zh-rCN/string.xml b/presentation/src/main/res/values-zh-rCN/string.xml index 285f258e..df3726e1 100644 --- a/presentation/src/main/res/values-zh-rCN/string.xml +++ b/presentation/src/main/res/values-zh-rCN/string.xml @@ -109,7 +109,10 @@ 转发至… - 已选择 %1$d 个会话 + + 已选择 %1$d 个会话 + 已选择 %1$d 个会话 + 发送 已归档会话 新消息 diff --git a/presentation/src/main/res/values/string.xml b/presentation/src/main/res/values/string.xml index 8be65fc4..558bfcca 100644 --- a/presentation/src/main/res/values/string.xml +++ b/presentation/src/main/res/values/string.xml @@ -110,7 +110,10 @@ Forward to... - %1$d chats selected + + %1$d chats selected + %1$d chats selected + Send Archived Chats New Chat From f5b0912afd0afe267ea93bb843acf211207e475e Mon Sep 17 00:00:00 2001 From: Artur Skubei <41114720+gdlbo@users.noreply.github.com> Date: Wed, 15 Apr 2026 17:10:54 +0300 Subject: [PATCH 5/5] update translations for poll creation, push diagnostics, and AI editor - add missing localized strings for poll creation (questions, options, quiz mode, and expiration settings) across multiple languages including pt-BR, hy, ru, sk, es, tr, uk, and zh-CN --- .../src/main/res/values-es/string.xml | 62 +++++++++++ .../src/main/res/values-hy/string.xml | 102 ++++++++++++++++++ .../src/main/res/values-pt-rBR/string.xml | 32 ++++++ .../src/main/res/values-ru-rRU/string.xml | 33 ++++++ .../src/main/res/values-sk/string.xml | 33 ++++++ .../src/main/res/values-tr/strings.xml | 25 +++++ .../src/main/res/values-uk/string.xml | 33 ++++++ .../src/main/res/values-zh-rCN/string.xml | 33 ++++++ 8 files changed, 353 insertions(+) diff --git a/presentation/src/main/res/values-es/string.xml b/presentation/src/main/res/values-es/string.xml index 783be987..2deded2c 100644 --- a/presentation/src/main/res/values-es/string.xml +++ b/presentation/src/main/res/values-es/string.xml @@ -2073,4 +2073,66 @@ Borrar fecha Elegir fecha Selecciona las respuestas correctas a continuacion. + Unknown country + Push Diagnostics + Runtime Flags + Push Environment + UnifiedPush Details + Sponsor + Danger Zone + UnifiedPush (Simple Push) + Archivo + %1$d archivos adjuntos + Crear encuesta + Pregunta + Opciones + Opcion + Anadir opcion + Descripcion + Modo cuestionario + Respuesta correcta + Explicacion + Permitir volver a votar + Mezclar opciones + Ocultar resultados hasta el cierre + Enviar como cerrada + Periodo de apertura + Fecha de cierre + No establecido + Elegir + AI + AI editor + Translate + Stylize + Fix + Original + Result + Changes + Apply result + Translate text + Apply style + Fix text + Target language + Select language + No languages found + Formal + Short + Tribal + Corp + Biblical + Viking + Zen + Add emojis + Processing... + Enter text to use AI + Too many AI requests. Telegram Premium may be required. + AI processing failed + Resultados ocultos hasta el cierre + Revotacion habilitada + Opciones mezcladas + Periodo de apertura: %1$s + Cierra el: %1$s + Tiempo restante: %1$s + + diff --git a/presentation/src/main/res/values-hy/string.xml b/presentation/src/main/res/values-hy/string.xml index f49bfb10..b19f9ef8 100644 --- a/presentation/src/main/res/values-hy/string.xml +++ b/presentation/src/main/res/values-hy/string.xml @@ -1909,4 +1909,106 @@ Մաքրել ամսաթիվը Ընտրել ամսաթիվ Ստորեւ ընտրեք ճիշտ պատասխանները։ + Unknown country + Push Diagnostics + Runtime Flags + Push Environment + UnifiedPush Details + Sponsor + Danger Zone + UnifiedPush (Simple Push) + Sticker size + Ֆայլ + %1$d ֆայլ կցված է + Ստեղծել հարցում + Manage + Limited photo access enabled + Only selected photos and videos are visible. + Attachments + Other sources + Հարց + Տարբերակներ + Տարբերակ + Ավելացնել տարբերակ + Նկարագրություն + Քվիզի ռեժիմ + Ճիշտ տարբերակ + Բացատրություն + Թույլատրել վերաքվեարկություն + Խառնել տարբերակները + Թաքցնել արդյունքները մինչև փակվելը + Ուղարկել որպես փակված + Բացման ժամանակահատված + Փակման ամսաթիվ + Սահմանված չէ + Ընտրել + All + Photos + Videos + All folders + Screenshots + %1$d selected + Ready to attach + Allow media access + Grant access to photos and videos to attach files in chat. + Grant access + Undo + Redo + Preview + Edit + Markdown: on + Markdown: off + A+ + A- + Snippets + Save current as snippet + Snippet title + No snippets yet + Find + Replace + Replace all + %1$d of %2$d matches + No matches + %1$d words + ~%1$d min read + Draft auto-saved + AI + AI editor + Translate + Stylize + Fix + Original + Result + Changes + Apply result + Translate text + Apply style + Fix text + Target language + Select language + No languages found + Formal + Short + Tribal + Corp + Biblical + Viking + Zen + Add emojis + Processing... + Enter text to use AI + Too many AI requests. Telegram Premium may be required. + AI processing failed + Insert + Prev + Next + Արդյունքները թաքցված են մինչև փակվելը + Վերաքվեարկությունը միացված է + Տարբերակների խառնումը միացված է + Բացման ժամանակահատված՝ %1$s + Կփակվի՝ %1$s + Մնացած ժամանակը՝ %1$s + Invalid confirmation code + Invalid password + An unexpected error occurred diff --git a/presentation/src/main/res/values-pt-rBR/string.xml b/presentation/src/main/res/values-pt-rBR/string.xml index 182a9b06..196f3406 100644 --- a/presentation/src/main/res/values-pt-rBR/string.xml +++ b/presentation/src/main/res/values-pt-rBR/string.xml @@ -2102,4 +2102,36 @@ Limpar data Escolher data Selecione as respostas corretas abaixo. + Push Diagnostics + Runtime Flags + Push Environment + UnifiedPush Details + Sponsor + Danger Zone + UnifiedPush (Simple Push) + Arquivo + %1$d arquivos anexados + Criar enquete + Pergunta + Opcoes + Opcao + Adicionar opcao + Descricao + Modo quiz + Opcao correta + Explicacao + Permitir votar novamente + Embaralhar opcoes + Ocultar resultados ate o encerramento + Enviar como encerrada + Periodo de abertura + Data de encerramento + Nao definido + Escolher + Resultados ocultos ate o encerramento + Revotacao ativada + Embaralhamento ativado + Periodo de abertura: %1$s + Encerra em: %1$s + Tempo restante: %1$s diff --git a/presentation/src/main/res/values-ru-rRU/string.xml b/presentation/src/main/res/values-ru-rRU/string.xml index bb74e3d5..805558d4 100644 --- a/presentation/src/main/res/values-ru-rRU/string.xml +++ b/presentation/src/main/res/values-ru-rRU/string.xml @@ -2091,4 +2091,37 @@ Очистить дату Выбрать дату Выберите правильные ответы ниже. + Sponsor + Push Diagnostics + Runtime Flags + Push Environment + UnifiedPush Details + Sponsor + Danger Zone + UnifiedPush (Simple Push) + Файл + %1$d файлов прикреплено + Создать опрос + Вопрос + Варианты + Вариант + Добавить вариант + Описание + Режим викторины + Правильный вариант + Пояснение + Разрешить переголосование + Перемешать варианты + Скрыть результаты до закрытия + Отправить закрытым + Период открытия + Дата закрытия + Не задано + Выбрать + Результаты скрыты до закрытия + Переголосование включено + Перемешивание вариантов включено + Период открытия: %1$s + Закроется: %1$s + Осталось времени: %1$s diff --git a/presentation/src/main/res/values-sk/string.xml b/presentation/src/main/res/values-sk/string.xml index b292c9b6..cccbcaad 100644 --- a/presentation/src/main/res/values-sk/string.xml +++ b/presentation/src/main/res/values-sk/string.xml @@ -2221,4 +2221,37 @@ Vymazat datum Vybrat datum Nizsie vyberte spravne odpovede. + Push Diagnostics + Runtime Flags + Push Environment + UnifiedPush Details + Sponsor + Danger Zone + UnifiedPush (Simple Push) + Message letter spacing + Subor + %1$d suborov pripojenych + Vytvorit anketu + Otazka + Moznosti + Moznost + Pridat moznost + Popis + Rezim kvizu + Spravna moznost + Vysvetlenie + Povolit opakovane hlasovanie + Premiesat moznosti + Skryt vysledky do uzavretia + Odoslat ako uzavrete + Obdobie otvorenia + Datum uzavretia + Nenastavene + Vybrat + Vysledky su skryte do uzavretia + Opakovane hlasovanie je povolene + Premiesanie moznosti je zapnute + Obdobie otvorenia: %1$s + Ukoncenie: %1$s + Zostavajuci cas: %1$s diff --git a/presentation/src/main/res/values-tr/strings.xml b/presentation/src/main/res/values-tr/strings.xml index 8e9f74dd..90df0494 100644 --- a/presentation/src/main/res/values-tr/strings.xml +++ b/presentation/src/main/res/values-tr/strings.xml @@ -2087,4 +2087,29 @@ Tarihi temizle Tarih sec Asagidan dogru cevaplari secin. + Dosya + %1$d dosya eklendi + Anket olustur + Soru + Secenekler + Secenek + Secenek ekle + Aciklama + Quiz modu + Dogru secenek + Aciklama + Yeniden oylamaya izin ver + Secenekleri karistir + Sonuclari kapanana kadar gizle + Kapali olarak gonder + Acik kalma suresi + Kapanis tarihi + Ayarlanmadi + Sec + Sonuclar kapanana kadar gizli + Yeniden oylama acik + Secenek karistirma acik + Acik kalma suresi: %1$s + Kapanis: %1$s + Kalan sure: %1$s diff --git a/presentation/src/main/res/values-uk/string.xml b/presentation/src/main/res/values-uk/string.xml index 46aa271e..a1170dfa 100644 --- a/presentation/src/main/res/values-uk/string.xml +++ b/presentation/src/main/res/values-uk/string.xml @@ -2091,4 +2091,37 @@ Очистити дату Вибрати дату Оберіть правильні відповіді нижче. + Sponsor + Push Diagnostics + Runtime Flags + Push Environment + UnifiedPush Details + Sponsor + Danger Zone + UnifiedPush (Simple Push) + Файл + %1$d файлів прикріплено + Створити опитування + Питання + Варіанти + Варіант + Додати варіант + Опис + Режим вікторини + Правильний варіант + Пояснення + Дозволити переголосування + Перемішати варіанти + Приховати результати до закриття + Надіслати закритим + Період відкриття + Дата закриття + Не встановлено + Вибрати + Результати приховані до закриття + Переголосування увімкнено + Перемішування варіантів увімкнено + Період відкриття: %1$s + Закриється: %1$s + Залишилось часу: %1$s diff --git a/presentation/src/main/res/values-zh-rCN/string.xml b/presentation/src/main/res/values-zh-rCN/string.xml index df3726e1..2d5c810e 100644 --- a/presentation/src/main/res/values-zh-rCN/string.xml +++ b/presentation/src/main/res/values-zh-rCN/string.xml @@ -2070,4 +2070,37 @@ 清除日期 选择日期 请在下方选择正确答案。 + Sponsor + Push Diagnostics + Runtime Flags + Push Environment + UnifiedPush Details + Sponsor + Danger Zone + UnifiedPush (Simple Push) + 文件 + 已附加 %1$d 个文件 + 创建投票 + 问题 + 选项 + 选项 + 添加选项 + 描述 + 测验模式 + 正确选项 + 说明 + 允许重新投票 + 随机排列选项 + 在关闭前隐藏结果 + 作为已关闭发送 + 开放时长 + 关闭日期 + 未设置 + 选择 + 结果将在关闭前隐藏 + 已启用重新投票 + 已启用选项随机 + 开放时长:%1$s + 关闭时间:%1$s + 剩余时间:%1$s