Merge branch 'feature/ons/poll' into feature/ons/poll_timeline

* feature/ons/poll:
  Design review fixes.
  Code review fixes.
  Make the poll option visible so that it can be tested from the PR
  Limit maximum number of poll options.
  Code review fixes.
  Fix UI issues.
  Remove poll command.
  Use unstable types.
  Create poll event content.
  Create poll UI implementation.
  Create poll fragment with a title.
  Create poll screen components implemented.
  Add poll icon to attachment type selector.
This commit is contained in:
Onuray Sahin 2021-11-09 13:19:50 +03:00
commit e0abd991c5
40 changed files with 1027 additions and 36 deletions

1
changelog.d/4367.feature Normal file
View file

@ -0,0 +1 @@
Poll Feature - Create Poll Screen

View file

@ -0,0 +1,6 @@
<?xml version="1.0" encoding="utf-8"?>
<selector xmlns:android="http://schemas.android.com/apk/res/android">
<item android:color="?vctr_content_tertiary" android:state_enabled="false" />
<item android:color="?vctr_content_tertiary" android:state_focused="false" />
<item android:color="?colorPrimary" />
</selector>

View file

@ -0,0 +1,7 @@
<?xml version="1.0" encoding="utf-8"?>
<selector xmlns:android="http://schemas.android.com/apk/res/android">
<item android:color="?colorPrimary" android:state_focused="true"/>
<item android:color="?colorPrimary" android:state_hovered="true"/>
<item android:color="?colorPrimary" android:state_enabled="false"/>
<item android:color="?vctr_content_quinary"/>
</selector>

View file

@ -19,4 +19,9 @@
<item name="android:textColor">?vctr_message_text_color</item>
</style>
<style name="Widget.Vector.EditText.Form" parent="Widget.MaterialComponents.TextInputLayout.OutlinedBox">
<item name="boxStrokeColor">@color/form_edit_text_stroke_color_selector</item>
<item name="android:textColorHint">@color/form_edit_text_hint_color_selector</item>
</style>
</resources>

View file

@ -0,0 +1,10 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<style name="Widget.Vector.Button.CreatePoll" parent="Widget.Vector.Button">
<item name="android:backgroundTint">@color/button_background_tint_selector</item>
<item name="android:textAppearance">@style/TextAppearance.Vector.Button</item>
<item name="android:textColor">@android:color/white</item>
</style>
</resources>

View file

@ -102,6 +102,9 @@ object EventType {
// Relation Events
const val REACTION = "m.reaction"
// Poll
const val POLL_START = "org.matrix.msc3381.poll.start"
// Unwedging
internal const val DUMMY = "m.dummy"

View file

@ -0,0 +1,25 @@
/*
* Copyright 2021 The Matrix.org Foundation C.I.C.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.matrix.android.sdk.api.session.room.model.message
import com.squareup.moshi.Json
import com.squareup.moshi.JsonClass
@JsonClass(generateAdapter = true)
data class MessagePollContent(
@Json(name = "org.matrix.msc3381.poll.start") val pollCreationInfo: PollCreationInfo? = null
)

View file

@ -0,0 +1,26 @@
/*
* Copyright 2021 The Matrix.org Foundation C.I.C.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.matrix.android.sdk.api.session.room.model.message
import com.squareup.moshi.Json
import com.squareup.moshi.JsonClass
@JsonClass(generateAdapter = true)
data class PollAnswer(
@Json(name = "id") val id: String? = null,
@Json(name = "org.matrix.msc1767.text") val answer: String? = null
)

View file

@ -0,0 +1,28 @@
/*
* Copyright 2021 The Matrix.org Foundation C.I.C.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.matrix.android.sdk.api.session.room.model.message
import com.squareup.moshi.Json
import com.squareup.moshi.JsonClass
@JsonClass(generateAdapter = true)
data class PollCreationInfo(
@Json(name = "question") val question: PollQuestion? = null,
@Json(name = "kind") val kind: String? = "org.matrix.msc3381.poll.disclosed",
@Json(name = "max_selections") val maxSelections: Int = 1,
@Json(name = "answers") val answers: List<PollAnswer>? = null
)

View file

@ -0,0 +1,25 @@
/*
* Copyright 2021 The Matrix.org Foundation C.I.C.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.matrix.android.sdk.api.session.room.model.message
import com.squareup.moshi.Json
import com.squareup.moshi.JsonClass
@JsonClass(generateAdapter = true)
data class PollQuestion(
@Json(name = "org.matrix.msc1767.text") val question: String? = null
)

View file

@ -84,10 +84,10 @@ interface SendService {
/**
* Send a poll to the room.
* @param question the question
* @param options list of (label, value)
* @param options list of options
* @return a [Cancelable]
*/
fun sendPoll(question: String, options: List<OptionItem>): Cancelable
fun sendPoll(question: String, options: List<String>): Cancelable
/**
* Method to send a poll response.

View file

@ -98,7 +98,7 @@ internal class DefaultSendService @AssistedInject constructor(
.let { sendEvent(it) }
}
override fun sendPoll(question: String, options: List<OptionItem>): Cancelable {
override fun sendPoll(question: String, options: List<String>): Cancelable {
return localEchoEventFactory.createPollEvent(roomId, question, options)
.also { createLocalEcho(it) }
.let { sendEvent(it) }

View file

@ -39,12 +39,16 @@ import org.matrix.android.sdk.api.session.room.model.message.MessageFileContent
import org.matrix.android.sdk.api.session.room.model.message.MessageFormat
import org.matrix.android.sdk.api.session.room.model.message.MessageImageContent
import org.matrix.android.sdk.api.session.room.model.message.MessageOptionsContent
import org.matrix.android.sdk.api.session.room.model.message.MessagePollContent
import org.matrix.android.sdk.api.session.room.model.message.MessagePollResponseContent
import org.matrix.android.sdk.api.session.room.model.message.MessageTextContent
import org.matrix.android.sdk.api.session.room.model.message.MessageType
import org.matrix.android.sdk.api.session.room.model.message.MessageVideoContent
import org.matrix.android.sdk.api.session.room.model.message.OPTION_TYPE_POLL
import org.matrix.android.sdk.api.session.room.model.message.OptionItem
import org.matrix.android.sdk.api.session.room.model.message.PollAnswer
import org.matrix.android.sdk.api.session.room.model.message.PollCreationInfo
import org.matrix.android.sdk.api.session.room.model.message.PollQuestion
import org.matrix.android.sdk.api.session.room.model.message.ThumbnailInfo
import org.matrix.android.sdk.api.session.room.model.message.VideoInfo
import org.matrix.android.sdk.api.session.room.model.relation.ReactionContent
@ -138,24 +142,29 @@ internal class LocalEchoEventFactory @Inject constructor(
fun createPollEvent(roomId: String,
question: String,
options: List<OptionItem>): Event {
val compatLabel = buildString {
append("[Poll] ")
append(question)
options.forEach {
append("\n")
append(it.value)
}
}
return createMessageEvent(
roomId,
MessageOptionsContent(
body = compatLabel,
label = question,
optionType = OPTION_TYPE_POLL,
options = options.toList()
options: List<String>): Event {
val content = MessagePollContent(
pollCreationInfo = PollCreationInfo(
question = PollQuestion(
question = question
),
answers = options.mapIndexed { index, option ->
PollAnswer(
id = index.toString(),
answer = option
)
}
)
)
val localId = LocalEcho.createLocalEchoId()
return Event(
roomId = roomId,
originServerTs = dummyOriginServerTs(),
senderId = userId,
eventId = localId,
type = EventType.POLL_START,
content = content.toContent(),
unsignedData = UnsignedData(age = null, transactionId = localId))
}
fun createReplaceTextOfReply(roomId: String,

View file

@ -339,6 +339,7 @@
<activity android:name=".features.spaces.manage.SpaceManageActivity" />
<activity android:name=".features.spaces.people.SpacePeopleActivity" />
<activity android:name=".features.spaces.leave.SpaceLeaveAdvancedActivity" />
<activity android:name=".features.poll.create.CreatePollActivity" />
<!-- Services -->

View file

@ -26,6 +26,7 @@ import dagger.hilt.android.components.ActivityComponent
import dagger.multibindings.IntoMap
import im.vector.app.features.attachments.preview.AttachmentsPreviewFragment
import im.vector.app.features.contactsbook.ContactsBookFragment
import im.vector.app.features.poll.create.CreatePollFragment
import im.vector.app.features.crypto.keysbackup.settings.KeysBackupSettingsFragment
import im.vector.app.features.crypto.quads.SharedSecuredStorageKeyFragment
import im.vector.app.features.crypto.quads.SharedSecuredStoragePassphraseFragment
@ -837,4 +838,9 @@ interface FragmentModule {
@IntoMap
@FragmentKey(SpaceLeaveAdvancedFragment::class)
fun bindSpaceLeaveAdvancedFragment(fragment: SpaceLeaveAdvancedFragment): Fragment
@Binds
@IntoMap
@FragmentKey(CreatePollFragment::class)
fun bindCreatePollFragment(fragment: CreatePollFragment): Fragment
}

View file

@ -26,6 +26,7 @@ import im.vector.app.features.call.conference.JitsiCallViewModel
import im.vector.app.features.call.transfer.CallTransferViewModel
import im.vector.app.features.contactsbook.ContactsBookViewModel
import im.vector.app.features.createdirect.CreateDirectRoomViewModel
import im.vector.app.features.poll.create.CreatePollViewModel
import im.vector.app.features.crypto.keysbackup.settings.KeysBackupSettingsViewModel
import im.vector.app.features.crypto.quads.SharedSecureStorageViewModel
import im.vector.app.features.crypto.recover.BootstrapSharedViewModel
@ -546,4 +547,9 @@ interface MavericksViewModelModule {
@IntoMap
@MavericksViewModelKey(VerificationBottomSheetViewModel::class)
fun verificationBottomSheetViewModelFactory(factory: VerificationBottomSheetViewModel.Factory): MavericksAssistedViewModelFactory<*, *>
@Binds
@IntoMap
@MavericksViewModelKey(CreatePollViewModel::class)
fun createPollViewModelFactory(factory: CreatePollViewModel.Factory): MavericksAssistedViewModelFactory<*, *>
}

View file

@ -15,6 +15,8 @@
*/
package im.vector.app.core.ui.list
import android.graphics.Typeface
import android.view.Gravity
import androidx.annotation.ColorInt
import androidx.annotation.DrawableRes
import com.airbnb.epoxy.EpoxyAttribute
@ -47,6 +49,12 @@ abstract class GenericButtonItem : VectorEpoxyModel<GenericButtonItem.Holder>()
@DrawableRes
var iconRes: Int? = null
@EpoxyAttribute
var gravity: Int = Gravity.CENTER
@EpoxyAttribute
var bold: Boolean = false
override fun bind(holder: Holder) {
super.bind(holder)
holder.button.text = text
@ -58,6 +66,10 @@ abstract class GenericButtonItem : VectorEpoxyModel<GenericButtonItem.Holder>()
holder.button.icon = null
}
holder.button.gravity = gravity or Gravity.CENTER_VERTICAL
val textStyle = if (bold) Typeface.BOLD else Typeface.NORMAL
holder.button.setTypeface(null, textStyle)
holder.button.onClick(buttonClickAction)
}

View file

@ -75,6 +75,7 @@ class AttachmentTypeSelectorView(context: Context,
views.attachmentStickersButton.configure(Type.STICKER)
views.attachmentAudioButton.configure(Type.AUDIO)
views.attachmentContactButton.configure(Type.CONTACT)
views.attachmentPollButton.configure(Type.POLL)
width = LinearLayout.LayoutParams.MATCH_PARENT
height = LinearLayout.LayoutParams.WRAP_CONTENT
animationStyle = 0
@ -108,6 +109,7 @@ class AttachmentTypeSelectorView(context: Context,
animateButtonIn(views.attachmentAudioButton, 0)
animateButtonIn(views.attachmentContactButton, ANIMATION_DURATION / 4)
animateButtonIn(views.attachmentStickersButton, ANIMATION_DURATION / 2)
animateButtonIn(views.attachmentPollButton, ANIMATION_DURATION / 4)
}
override fun dismiss() {
@ -212,6 +214,7 @@ class AttachmentTypeSelectorView(context: Context,
FILE(PERMISSIONS_EMPTY),
STICKER(PERMISSIONS_EMPTY),
AUDIO(PERMISSIONS_EMPTY),
CONTACT(PERMISSIONS_FOR_PICKING_CONTACT)
CONTACT(PERMISSIONS_FOR_PICKING_CONTACT),
POLL(PERMISSIONS_EMPTY)
}
}

View file

@ -47,7 +47,6 @@ enum class Command(val command: String, val parameters: String, @StringRes val d
RAINBOW_EMOTE("/rainbowme", "<message>", R.string.command_description_rainbow_emote, false),
CLEAR_SCALAR_TOKEN("/clear_scalar_token", "", R.string.command_description_clear_scalar_token, false),
SPOILER("/spoiler", "<message>", R.string.command_description_spoiler, false),
POLL("/poll", "Question | Option 1 | Option 2 ...", R.string.command_description_poll, false),
SHRUG("/shrug", "<message>", R.string.command_description_shrug, false),
LENNY("/lenny", "<message>", R.string.command_description_lenny, false),
PLAIN("/plain", "<message>", R.string.command_description_plain, false),

View file

@ -345,15 +345,6 @@ object CommandParser {
ParsedCommand.SendLenny(message)
}
Command.POLL.command -> {
val rawCommand = textMessage.substring(Command.POLL.command.length).trim()
val split = rawCommand.split("|").map { it.trim() }
if (split.size > 2) {
ParsedCommand.SendPoll(split[0], split.subList(1, split.size))
} else {
ParsedCommand.ErrorSyntax(Command.POLL)
}
}
Command.DISCARD_SESSION.command -> {
ParsedCommand.DiscardSession
}

View file

@ -61,7 +61,6 @@ sealed class ParsedCommand {
class SendSpoiler(val message: String) : ParsedCommand()
class SendShrug(val message: CharSequence) : ParsedCommand()
class SendLenny(val message: CharSequence) : ParsedCommand()
class SendPoll(val question: String, val options: List<String>) : ParsedCommand()
object DiscardSession : ParsedCommand()
class ShowUser(val userId: String) : ParsedCommand()
class SendChatEffect(val chatEffect: ChatEffect, val message: String) : ParsedCommand()

View file

@ -0,0 +1,99 @@
/*
* Copyright (c) 2021 New Vector Ltd
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package im.vector.app.features.form
import android.text.Editable
import android.widget.ImageButton
import com.airbnb.epoxy.EpoxyAttribute
import com.airbnb.epoxy.EpoxyModelClass
import com.google.android.material.textfield.TextInputEditText
import com.google.android.material.textfield.TextInputLayout
import im.vector.app.R
import im.vector.app.core.epoxy.ClickListener
import im.vector.app.core.epoxy.TextListener
import im.vector.app.core.epoxy.VectorEpoxyHolder
import im.vector.app.core.epoxy.VectorEpoxyModel
import im.vector.app.core.epoxy.addTextChangedListenerOnce
import im.vector.app.core.epoxy.onClick
import im.vector.app.core.extensions.setTextIfDifferent
import im.vector.app.core.platform.SimpleTextWatcher
@EpoxyModelClass(layout = R.layout.item_form_text_input_with_delete)
abstract class FormEditTextWithDeleteItem : VectorEpoxyModel<FormEditTextWithDeleteItem.Holder>() {
@EpoxyAttribute
var hint: String? = null
@EpoxyAttribute
var value: String? = null
@EpoxyAttribute
var enabled: Boolean = true
@EpoxyAttribute
var singleLine: Boolean = true
@EpoxyAttribute
var imeOptions: Int? = null
@EpoxyAttribute(EpoxyAttribute.Option.DoNotHash)
var onTextChange: TextListener? = null
@EpoxyAttribute(EpoxyAttribute.Option.DoNotHash)
var onDeleteClicked: ClickListener? = null
private val onTextChangeListener = object : SimpleTextWatcher() {
override fun afterTextChanged(s: Editable) {
onTextChange?.invoke(s.toString())
}
}
override fun bind(holder: Holder) {
super.bind(holder)
holder.textInputLayout.isEnabled = enabled
holder.textInputLayout.hint = hint
holder.textInputEditText.setTextIfDifferent(value)
holder.textInputEditText.isEnabled = enabled
if (singleLine) {
holder.textInputEditText.setSingleLine()
}
imeOptions?.let {
holder.textInputEditText.imeOptions = it
}
holder.textInputEditText.addTextChangedListenerOnce(onTextChangeListener)
holder.textInputDeleteButton.onClick(onDeleteClicked)
}
override fun shouldSaveViewState(): Boolean {
return false
}
override fun unbind(holder: Holder) {
super.unbind(holder)
holder.textInputEditText.removeTextChangedListener(onTextChangeListener)
}
class Holder : VectorEpoxyHolder() {
val textInputLayout by bind<TextInputLayout>(R.id.formTextInputTextInputLayout)
val textInputEditText by bind<TextInputEditText>(R.id.formTextInputTextInputEditText)
val textInputDeleteButton by bind<ImageButton>(R.id.formTextInputDeleteButton)
}
}

View file

@ -2158,6 +2158,7 @@ class RoomDetailFragment @Inject constructor(
AttachmentTypeSelectorView.Type.AUDIO -> attachmentsHelper.selectAudio(attachmentAudioActivityResultLauncher)
AttachmentTypeSelectorView.Type.CONTACT -> attachmentsHelper.selectContact(attachmentContactActivityResultLauncher)
AttachmentTypeSelectorView.Type.STICKER -> roomDetailViewModel.handle(RoomDetailAction.SelectStickerAttachment)
AttachmentTypeSelectorView.Type.POLL -> navigator.openCreatePoll(requireContext(), roomDetailArgs.roomId)
}.exhaustive
}

View file

@ -259,11 +259,6 @@ class TextComposerViewModel @AssistedInject constructor(
_viewEvents.post(TextComposerViewEvents.SlashCommandResultOk())
popDraft()
}
is ParsedCommand.SendPoll -> {
room.sendPoll(slashCommandResult.question, slashCommandResult.options.mapIndexed { index, s -> OptionItem(s, "$index. $s") })
_viewEvents.post(TextComposerViewEvents.SlashCommandResultOk())
popDraft()
}
is ParsedCommand.ChangeTopic -> {
handleChangeTopicSlashCommand(slashCommandResult)
}

View file

@ -40,6 +40,9 @@ import im.vector.app.features.call.conference.JitsiCallViewModel
import im.vector.app.features.call.conference.VectorJitsiActivity
import im.vector.app.features.call.transfer.CallTransferActivity
import im.vector.app.features.createdirect.CreateDirectRoomActivity
import im.vector.app.features.poll.create.CreatePollActivity
import im.vector.app.features.poll.create.CreatePollArgs
import im.vector.app.features.poll.create.CreatePollViewModel
import im.vector.app.features.crypto.keysbackup.settings.KeysBackupManageActivity
import im.vector.app.features.crypto.keysbackup.setup.KeysBackupSetupActivity
import im.vector.app.features.crypto.recover.BootstrapBottomSheet
@ -498,6 +501,14 @@ class DefaultNavigator @Inject constructor(
context.startActivity(intent)
}
override fun openCreatePoll(context: Context, roomId: String) {
val intent = CreatePollActivity.getIntent(
context,
CreatePollArgs(roomId = roomId)
)
context.startActivity(intent)
}
private fun startActivity(context: Context, intent: Intent, buildTask: Boolean) {
if (buildTask) {
val stackBuilder = TaskStackBuilder.create(context)

View file

@ -140,4 +140,6 @@ interface Navigator {
fun openDevTools(context: Context, roomId: String)
fun openCallTransfer(context: Context, callId: String)
fun openCreatePoll(context: Context, roomId: String)
}

View file

@ -0,0 +1,27 @@
/*
* Copyright (c) 2021 New Vector Ltd
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package im.vector.app.features.poll.create
import im.vector.app.core.platform.VectorViewModelAction
sealed class CreatePollAction : VectorViewModelAction {
data class OnQuestionChanged(val question: String) : CreatePollAction()
data class OnOptionChanged(val index: Int, val option: String) : CreatePollAction()
data class OnDeleteOption(val index: Int) : CreatePollAction()
object OnAddOption : CreatePollAction()
object OnCreatePoll : CreatePollAction()
}

View file

@ -0,0 +1,56 @@
/*
* Copyright (c) 2021 New Vector Ltd
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package im.vector.app.features.poll.create
import android.content.Context
import android.content.Intent
import android.os.Bundle
import android.view.View
import im.vector.app.R
import im.vector.app.core.extensions.addFragment
import im.vector.app.core.platform.SimpleFragmentActivity
import dagger.hilt.android.AndroidEntryPoint
@AndroidEntryPoint
class CreatePollActivity : SimpleFragmentActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
views.toolbar.visibility = View.GONE
val createPollArgs: CreatePollArgs? = intent?.extras?.getParcelable(EXTRA_CREATE_POLL_ARGS)
if (isFirstCreation()) {
addFragment(
R.id.container,
CreatePollFragment::class.java,
createPollArgs
)
}
}
companion object {
private const val EXTRA_CREATE_POLL_ARGS = "EXTRA_CREATE_POLL_ARGS"
fun getIntent(context: Context, createPollArgs: CreatePollArgs): Intent {
return Intent(context, CreatePollActivity::class.java).apply {
putExtra(EXTRA_CREATE_POLL_ARGS, createPollArgs)
}
}
}
}

View file

@ -0,0 +1,109 @@
/*
* Copyright (c) 2021 New Vector Ltd
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package im.vector.app.features.poll.create
import android.view.Gravity
import android.view.inputmethod.EditorInfo
import com.airbnb.epoxy.EpoxyController
import im.vector.app.R
import im.vector.app.core.resources.ColorProvider
import im.vector.app.core.resources.StringProvider
import im.vector.app.core.ui.list.ItemStyle
import im.vector.app.core.ui.list.genericButtonItem
import im.vector.app.core.ui.list.genericItem
import im.vector.app.features.form.formEditTextItem
import im.vector.app.features.form.formEditTextWithDeleteItem
import javax.inject.Inject
class CreatePollController @Inject constructor(
private val stringProvider: StringProvider,
private val colorProvider: ColorProvider
) : EpoxyController() {
private var state: CreatePollViewState? = null
var callback: Callback? = null
fun setData(state: CreatePollViewState) {
this.state = state
requestModelBuild()
}
override fun buildModels() {
val currentState = state ?: return
val host = this
genericItem {
id("question_title")
style(ItemStyle.BIG_TEXT)
title(host.stringProvider.getString(R.string.create_poll_question_title))
}
formEditTextItem {
id("question")
value(currentState.question)
hint(host.stringProvider.getString(R.string.create_poll_question_hint))
singleLine(false)
maxLength(500)
onTextChange {
host.callback?.onQuestionChanged(it)
}
}
genericItem {
id("options_title")
style(ItemStyle.BIG_TEXT)
title(host.stringProvider.getString(R.string.create_poll_options_title))
}
currentState.options.forEachIndexed { index, option ->
val imeOptions = if (index == currentState.options.size -1) EditorInfo.IME_ACTION_DONE else EditorInfo.IME_ACTION_NEXT
formEditTextWithDeleteItem {
id("option_$index")
value(option)
hint(host.stringProvider.getString(R.string.create_poll_options_hint, (index + 1)))
singleLine(true)
imeOptions(imeOptions)
onTextChange {
host.callback?.onOptionChanged(index, it)
}
onDeleteClicked {
host.callback?.onDeleteOption(index)
}
}
}
if (currentState.canAddMoreOptions) {
genericButtonItem {
id("add_option")
text(host.stringProvider.getString(R.string.create_poll_add_option))
textColor(host.colorProvider.getColor(R.color.palette_element_green))
gravity(Gravity.START)
bold(true)
buttonClickAction {
host.callback?.onAddOption()
}
}
}
}
interface Callback {
fun onQuestionChanged(question: String)
fun onOptionChanged(index: Int, option: String)
fun onDeleteOption(index: Int)
fun onAddOption()
}
}

View file

@ -0,0 +1,125 @@
/*
* Copyright (c) 2021 New Vector Ltd
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package im.vector.app.features.poll.create
import android.os.Bundle
import android.os.Parcelable
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import androidx.core.view.isVisible
import com.airbnb.mvrx.activityViewModel
import com.airbnb.mvrx.withState
import im.vector.app.R
import im.vector.app.core.extensions.configureWith
import im.vector.app.core.platform.VectorBaseFragment
import im.vector.app.databinding.FragmentCreatePollBinding
import kotlinx.parcelize.Parcelize
import javax.inject.Inject
@Parcelize
data class CreatePollArgs(
val roomId: String,
) : Parcelable
class CreatePollFragment @Inject constructor(
private val controller: CreatePollController
) : VectorBaseFragment<FragmentCreatePollBinding>(), CreatePollController.Callback {
private val viewModel: CreatePollViewModel by activityViewModel()
override fun getBinding(inflater: LayoutInflater, container: ViewGroup?): FragmentCreatePollBinding {
return FragmentCreatePollBinding.inflate(inflater, container, false)
}
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)
vectorBaseActivity.setSupportActionBar(views.createPollToolbar)
views.createPollRecyclerView.configureWith(controller)
controller.callback = this
views.createPollClose.debouncedClicks {
requireActivity().finish()
}
views.createPollButton.debouncedClicks {
viewModel.handle(CreatePollAction.OnCreatePoll)
}
viewModel.subscribe(this) {
views.createPollButton.isEnabled = it.canCreatePoll
}
viewModel.observeViewEvents {
when (it) {
CreatePollViewEvents.Success -> handleSuccess()
CreatePollViewEvents.EmptyQuestionError -> handleEmptyQuestionError()
is CreatePollViewEvents.NotEnoughOptionsError -> handleNotEnoughOptionsError(it.requiredOptionsCount)
}
}
}
override fun invalidate() = withState(viewModel) {
controller.setData(it)
}
override fun onQuestionChanged(question: String) {
viewModel.handle(CreatePollAction.OnQuestionChanged(question))
}
override fun onOptionChanged(index: Int, option: String) {
viewModel.handle(CreatePollAction.OnOptionChanged(index, option))
}
override fun onDeleteOption(index: Int) {
viewModel.handle(CreatePollAction.OnDeleteOption(index))
}
override fun onAddOption() {
viewModel.handle(CreatePollAction.OnAddOption)
}
private fun handleSuccess() {
requireActivity().finish()
}
private fun handleEmptyQuestionError() {
renderToast(getString(R.string.create_poll_empty_question_error))
}
private fun handleNotEnoughOptionsError(requiredOptionsCount: Int) {
renderToast(
resources.getQuantityString(
R.plurals.create_poll_not_enough_options_error,
requiredOptionsCount,
requiredOptionsCount
)
)
}
private fun renderToast(message: String) {
views.createPollToast.removeCallbacks(hideToastRunnable)
views.createPollToast.text = message
views.createPollToast.isVisible = true
views.createPollToast.postDelayed(hideToastRunnable, 2_000)
}
private val hideToastRunnable = Runnable {
views.createPollToast.isVisible = false
}
}

View file

@ -0,0 +1,25 @@
/*
* Copyright (c) 2021 New Vector Ltd
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package im.vector.app.features.poll.create
import im.vector.app.core.platform.VectorViewEvents
sealed class CreatePollViewEvents : VectorViewEvents {
object Success : CreatePollViewEvents()
object EmptyQuestionError : CreatePollViewEvents()
data class NotEnoughOptionsError(val requiredOptionsCount: Int) : CreatePollViewEvents()
}

View file

@ -0,0 +1,129 @@
/*
* Copyright (c) 2021 New Vector Ltd
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package im.vector.app.features.poll.create
import com.airbnb.mvrx.MavericksViewModelFactory
import dagger.assisted.Assisted
import dagger.assisted.AssistedFactory
import dagger.assisted.AssistedInject
import im.vector.app.core.di.MavericksAssistedViewModelFactory
import im.vector.app.core.di.hiltMavericksViewModelFactory
import im.vector.app.core.platform.VectorViewModel
import org.matrix.android.sdk.api.session.Session
class CreatePollViewModel @AssistedInject constructor(
@Assisted private val initialState: CreatePollViewState,
session: Session
) : VectorViewModel<CreatePollViewState, CreatePollAction, CreatePollViewEvents>(initialState) {
private val room = session.getRoom(initialState.roomId)!!
@AssistedFactory
interface Factory : MavericksAssistedViewModelFactory<CreatePollViewModel, CreatePollViewState> {
override fun create(initialState: CreatePollViewState): CreatePollViewModel
}
companion object : MavericksViewModelFactory<CreatePollViewModel, CreatePollViewState> by hiltMavericksViewModelFactory() {
const val MIN_OPTIONS_COUNT = 2
private const val MAX_OPTIONS_COUNT = 20
}
init {
observeState()
}
private fun observeState() {
onEach(
CreatePollViewState::question,
CreatePollViewState::options
) { question, options ->
setState {
copy(
canCreatePoll = canCreatePoll(question, options),
canAddMoreOptions = options.size < MAX_OPTIONS_COUNT
)
}
}
}
override fun handle(action: CreatePollAction) {
when (action) {
CreatePollAction.OnCreatePoll -> handleOnCreatePoll()
CreatePollAction.OnAddOption -> handleOnAddOption()
is CreatePollAction.OnDeleteOption -> handleOnDeleteOption(action.index)
is CreatePollAction.OnOptionChanged -> handleOnOptionChanged(action.index, action.option)
is CreatePollAction.OnQuestionChanged -> handleOnQuestionChanged(action.question)
}
}
private fun handleOnCreatePoll() = withState { state ->
val nonEmptyOptions = state.options.filter { it.isNotEmpty() }
when {
state.question.isEmpty() -> {
_viewEvents.post(CreatePollViewEvents.EmptyQuestionError)
}
nonEmptyOptions.size < MIN_OPTIONS_COUNT -> {
_viewEvents.post(CreatePollViewEvents.NotEnoughOptionsError(requiredOptionsCount = MIN_OPTIONS_COUNT))
}
else -> {
room.sendPoll(state.question, state.options)
_viewEvents.post(CreatePollViewEvents.Success)
}
}
}
private fun handleOnAddOption() {
setState {
val extendedOptions = options + ""
copy(
options = extendedOptions
)
}
}
private fun handleOnDeleteOption(index: Int) {
setState {
val filteredOptions = options.filterIndexed { ind, _ -> ind != index }
copy(
options = filteredOptions
)
}
}
private fun handleOnOptionChanged(index: Int, option: String) {
setState {
val changedOptions = options.mapIndexed { ind, s -> if (ind == index) option else s }
copy(
options = changedOptions
)
}
}
private fun handleOnQuestionChanged(question: String) {
setState {
copy(
question = question
)
}
}
private fun canCreatePoll(question: String, options: List<String>): Boolean {
return question.isNotEmpty() &&
options.filter { it.isNotEmpty() }.size >= MIN_OPTIONS_COUNT
}
}

View file

@ -0,0 +1,33 @@
/*
* Copyright (c) 2021 New Vector Ltd
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package im.vector.app.features.poll.create
import com.airbnb.mvrx.MavericksState
data class CreatePollViewState(
val roomId: String,
val question: String = "",
val options: List<String> = List(CreatePollViewModel.MIN_OPTIONS_COUNT) { "" },
val canCreatePoll: Boolean = false,
val canAddMoreOptions: Boolean = true
) : MavericksState {
constructor(args: CreatePollArgs) : this(
roomId = args.roomId
)
}

View file

@ -0,0 +1,10 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24">
<path
android:pathData="M10.5,2C10.2239,2 10,2.2239 10,2.5V22H14V2.5C14,2.2239 13.7761,2 13.5,2H10.5ZM3,9.5C3,9.2239 3.2239,9 3.5,9H6.5C6.7761,9 7,9.2239 7,9.5V22H3V9.5ZM17,13.5C17,13.2239 17.2239,13 17.5,13H20.5C20.7761,13 21,13.2239 21,13.5V22H17V13.5Z"
android:fillColor="#FFFFFF"
android:fillType="evenOdd"/>
</vector>

View file

@ -0,0 +1,18 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="10dp"
android:height="10dp"
android:viewportWidth="10"
android:viewportHeight="10">
<path
android:pathData="M0.9998,0.9997L8.9998,8.9997"
android:strokeWidth="2"
android:fillColor="#00000000"
android:strokeColor="#737D8C"
android:strokeLineCap="round"/>
<path
android:pathData="M9.0005,0.9997L1.0005,8.9997"
android:strokeWidth="2"
android:fillColor="#00000000"
android:strokeColor="#737D8C"
android:strokeLineCap="round"/>
</vector>

View file

@ -0,0 +1,98 @@
<?xml version="1.0" encoding="utf-8"?>
<androidx.constraintlayout.widget.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
xmlns:tools="http://schemas.android.com/tools"
android:layout_width="match_parent"
android:layout_height="match_parent">
<com.google.android.material.appbar.AppBarLayout
android:id="@+id/appBarLayout"
android:layout_width="match_parent"
android:layout_height="wrap_content"
app:layout_constraintTop_toTopOf="parent">
<com.google.android.material.appbar.MaterialToolbar
android:id="@+id/createPollToolbar"
android:layout_width="match_parent"
android:layout_height="?actionBarSize"
app:contentInsetStart="0dp">
<androidx.constraintlayout.widget.ConstraintLayout
android:layout_width="match_parent"
android:layout_height="match_parent">
<ImageView
android:id="@+id/createPollClose"
android:layout_width="@dimen/layout_touch_size"
android:layout_height="@dimen/layout_touch_size"
android:clickable="true"
android:contentDescription="@string/action_close"
android:focusable="true"
android:foreground="?attr/selectableItemBackground"
android:scaleType="center"
android:src="@drawable/ic_x_18dp"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent"
app:tint="?vctr_content_secondary"
tools:ignore="MissingPrefix" />
<TextView
android:id="@+id/createPollTitle"
style="@style/Widget.Vector.TextView.HeadlineMedium"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_marginStart="8dp"
android:layout_marginEnd="8dp"
android:ellipsize="end"
android:maxLines="1"
android:text="@string/create_poll_title"
android:textColor="?vctr_content_primary"
android:textStyle="bold"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintHorizontal_bias="0.0"
app:layout_constraintStart_toEndOf="@+id/createPollClose"
app:layout_constraintTop_toTopOf="parent" />
</androidx.constraintlayout.widget.ConstraintLayout>
</com.google.android.material.appbar.MaterialToolbar>
</com.google.android.material.appbar.AppBarLayout>
<androidx.recyclerview.widget.RecyclerView
android:id="@+id/createPollRecyclerView"
android:layout_width="match_parent"
android:layout_height="0dp"
android:overScrollMode="always"
app:layout_behavior="@string/appbar_scrolling_view_behavior"
app:layout_constraintBottom_toTopOf="@id/createPollButton"
app:layout_constraintTop_toBottomOf="@+id/appBarLayout"
tools:listitem="@layout/item_profile_action" />
<Button
android:id="@+id/createPollButton"
style="@style/Widget.Vector.Button.CreatePoll"
android:layout_width="match_parent"
android:layout_height="56dp"
android:layout_margin="16dp"
android:text="@string/create_poll_button"
app:layout_constraintBottom_toBottomOf="parent"
tools:enabled="false" />
<TextView
android:id="@+id/createPollToast"
style="@style/Widget.Vector.TextView.Caption.Toast"
android:layout_width="wrap_content"
android:layout_height="48dp"
android:layout_marginBottom="84dp"
android:accessibilityLiveRegion="polite"
android:visibility="gone"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
tools:text="@string/voice_message_release_to_send_toast"
tools:visibility="visible" />
</androidx.constraintlayout.widget.ConstraintLayout>

View file

@ -9,6 +9,7 @@
<com.google.android.material.textfield.TextInputLayout
android:id="@+id/formTextInputTextInputLayout"
style="@style/Widget.Vector.EditText.Form"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginStart="@dimen/layout_horizontal_margin"

View file

@ -0,0 +1,44 @@
<?xml version="1.0" encoding="utf-8"?>
<androidx.constraintlayout.widget.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
xmlns:tools="http://schemas.android.com/tools"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:background="?android:colorBackground"
android:minHeight="@dimen/item_form_min_height">
<com.google.android.material.textfield.TextInputLayout
android:id="@+id/formTextInputTextInputLayout"
style="@style/Widget.Vector.EditText.Form"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_marginStart="@dimen/layout_horizontal_margin"
android:layout_marginEnd="@dimen/layout_horizontal_margin"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toStartOf="@id/formTextInputDeleteButton"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent">
<com.google.android.material.textfield.TextInputEditText
android:id="@+id/formTextInputTextInputEditText"
android:layout_width="match_parent"
android:layout_height="wrap_content"
tools:hint="@string/create_room_name_hint" />
</com.google.android.material.textfield.TextInputLayout>
<ImageButton
android:id="@+id/formTextInputDeleteButton"
android:layout_width="24dp"
android:layout_height="24dp"
android:layout_marginEnd="@dimen/layout_horizontal_margin"
android:background="@drawable/circle"
android:contentDescription="@string/delete"
android:scaleType="center"
android:src="@drawable/ic_delete_10dp"
app:layout_constraintBottom_toBottomOf="@id/formTextInputTextInputLayout"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintTop_toTopOf="@id/formTextInputTextInputLayout"
app:tint="?vctr_content_secondary" />
</androidx.constraintlayout.widget.ConstraintLayout>

View file

@ -163,5 +163,36 @@
</LinearLayout>
</LinearLayout>
<LinearLayout
android:layout_width="match_parent"
android:layout_height="match_parent"
android:layout_margin="16dp"
android:baselineAligned="false"
android:orientation="horizontal"
android:visibility="visible"
android:weightSum="3">
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_weight="1"
android:gravity="center"
android:orientation="vertical">
<ImageButton
android:id="@+id/attachmentPollButton"
style="@style/AttachmentTypeSelectorButton"
android:contentDescription="@string/attachment_type_poll"
android:src="@drawable/ic_attachment_poll_white_24dp"
tools:background="?colorPrimary" />
<TextView
style="@style/AttachmentTypeSelectorLabel"
android:importantForAccessibility="no"
android:text="@string/attachment_type_poll" />
</LinearLayout>
</LinearLayout>
</LinearLayout>
</FrameLayout>

View file

@ -2435,6 +2435,7 @@
<string name="attachment_type_audio">"Audio"</string>
<string name="attachment_type_gallery">"Gallery"</string>
<string name="attachment_type_sticker">"Sticker"</string>
<string name="attachment_type_poll">Poll</string>
<string name="rotate_and_crop_screen_title">Rotate and crop</string>
<string name="error_handling_incoming_share">Couldn\'t handle share data</string>
@ -3630,4 +3631,18 @@
<string name="link_this_email_settings_link">Link this email with your account</string>
<!-- %s will be replaced by the value of link_this_email_settings_link and styled as a link -->
<string name="link_this_email_with_your_account">%s in Settings to receive invites directly in Element.</string>
<!-- Poll -->
<string name="create_poll_title">Create Poll</string>
<string name="create_poll_question_title">Poll question or topic</string>
<string name="create_poll_question_hint">Question or topic</string>
<string name="create_poll_options_title">Create options</string>
<string name="create_poll_options_hint">Option %1$d</string>
<string name="create_poll_add_option">ADD OPTION</string>
<string name="create_poll_button">CREATE POLL</string>
<string name="create_poll_empty_question_error">Question cannot be empty</string>
<plurals name="create_poll_not_enough_options_error">
<item quantity="one">At least %1$s option is required</item>
<item quantity="other">At least %1$s options are required</item>
</plurals>
</resources>