"Create poll" UI (#1143)
NB: This is missing analytics, which will be added once https://github.com/matrix-org/matrix-analytics-events/pull/85 is merged. Closes https://github.com/vector-im/element-meta/issues/2011
This commit is contained in:
parent
5a85459ec2
commit
633d5282d6
40 changed files with 1032 additions and 112 deletions
|
|
@ -1,70 +0,0 @@
|
|||
/*
|
||||
* Copyright (c) 2023 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 io.element.android.features.poll.impl
|
||||
|
||||
import android.os.Parcelable
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Modifier
|
||||
import com.bumble.appyx.core.composable.Children
|
||||
import com.bumble.appyx.core.modality.BuildContext
|
||||
import com.bumble.appyx.core.node.Node
|
||||
import com.bumble.appyx.core.plugin.Plugin
|
||||
import com.bumble.appyx.navmodel.backstack.BackStack
|
||||
import dagger.assisted.Assisted
|
||||
import dagger.assisted.AssistedInject
|
||||
import io.element.android.anvilannotations.ContributesNode
|
||||
import io.element.android.libraries.architecture.BackstackNode
|
||||
import io.element.android.libraries.architecture.animation.rememberDefaultTransitionHandler
|
||||
import io.element.android.libraries.architecture.createNode
|
||||
import io.element.android.libraries.di.SessionScope
|
||||
import kotlinx.parcelize.Parcelize
|
||||
|
||||
@ContributesNode(SessionScope::class)
|
||||
class PollFlowNode @AssistedInject constructor(
|
||||
@Assisted buildContext: BuildContext,
|
||||
@Assisted plugins: List<Plugin>,
|
||||
) : BackstackNode<PollFlowNode.NavTarget>(
|
||||
backstack = BackStack(
|
||||
initialElement = NavTarget.Root,
|
||||
savedStateMap = buildContext.savedStateMap,
|
||||
),
|
||||
buildContext = buildContext,
|
||||
plugins = plugins,
|
||||
) {
|
||||
|
||||
sealed interface NavTarget : Parcelable {
|
||||
@Parcelize
|
||||
data object Root : NavTarget
|
||||
}
|
||||
|
||||
override fun resolve(navTarget: NavTarget, buildContext: BuildContext): Node {
|
||||
return when (navTarget) {
|
||||
NavTarget.Root -> {
|
||||
createNode(buildContext)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
override fun View(modifier: Modifier) {
|
||||
Children(
|
||||
navModel = backstack,
|
||||
modifier = modifier,
|
||||
transitionHandler = rememberDefaultTransitionHandler(),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,31 @@
|
|||
/*
|
||||
* Copyright (c) 2023 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 io.element.android.features.poll.impl.create
|
||||
|
||||
import io.element.android.libraries.matrix.api.poll.PollKind
|
||||
|
||||
sealed interface CreatePollEvents {
|
||||
data object Create : CreatePollEvents
|
||||
data class SetQuestion(val question: String) : CreatePollEvents
|
||||
data class SetAnswer(val index: Int, val text: String) : CreatePollEvents
|
||||
data object AddAnswer : CreatePollEvents
|
||||
data class RemoveAnswer(val index: Int) : CreatePollEvents
|
||||
data class SetPollKind(val pollKind: PollKind) : CreatePollEvents
|
||||
data object NavBack : CreatePollEvents
|
||||
data object ConfirmNavBack : CreatePollEvents
|
||||
data object HideConfirmation : CreatePollEvents
|
||||
}
|
||||
|
|
@ -0,0 +1,56 @@
|
|||
/*
|
||||
* Copyright (c) 2023 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 io.element.android.features.poll.impl.create
|
||||
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Modifier
|
||||
import com.bumble.appyx.core.lifecycle.subscribe
|
||||
import com.bumble.appyx.core.modality.BuildContext
|
||||
import com.bumble.appyx.core.node.Node
|
||||
import com.bumble.appyx.core.plugin.Plugin
|
||||
import dagger.assisted.Assisted
|
||||
import dagger.assisted.AssistedInject
|
||||
import io.element.android.anvilannotations.ContributesNode
|
||||
import io.element.android.libraries.di.RoomScope
|
||||
|
||||
@ContributesNode(RoomScope::class)
|
||||
class CreatePollNode @AssistedInject constructor(
|
||||
@Assisted buildContext: BuildContext,
|
||||
@Assisted plugins: List<Plugin>,
|
||||
presenterFactory: CreatePollPresenter.Factory,
|
||||
// analyticsService: AnalyticsService, // TODO Polls: add analytics
|
||||
) : Node(buildContext, plugins = plugins) {
|
||||
|
||||
private val presenter = presenterFactory.create(backNavigator = ::navigateUp)
|
||||
|
||||
init {
|
||||
lifecycle.subscribe(
|
||||
onResume = {
|
||||
// TODO Polls: add analytics
|
||||
// analyticsService.screen(MobileScreen(screenName = MobileScreen.ScreenName.PollView))
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
override fun View(modifier: Modifier) {
|
||||
CreatePollView(
|
||||
state = presenter.present(),
|
||||
modifier = modifier,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,162 @@
|
|||
/*
|
||||
* Copyright (c) 2023 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 io.element.android.features.poll.impl.create
|
||||
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.MutableState
|
||||
import androidx.compose.runtime.derivedStateOf
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.rememberCoroutineScope
|
||||
import androidx.compose.runtime.saveable.Saver
|
||||
import androidx.compose.runtime.saveable.rememberSaveable
|
||||
import androidx.compose.runtime.setValue
|
||||
import dagger.assisted.Assisted
|
||||
import dagger.assisted.AssistedFactory
|
||||
import dagger.assisted.AssistedInject
|
||||
import io.element.android.libraries.architecture.Presenter
|
||||
import io.element.android.libraries.matrix.api.poll.PollKind
|
||||
import io.element.android.libraries.matrix.api.room.MatrixRoom
|
||||
import kotlinx.collections.immutable.ImmutableList
|
||||
import kotlinx.collections.immutable.toImmutableList
|
||||
import kotlinx.coroutines.launch
|
||||
import timber.log.Timber
|
||||
|
||||
private const val MIN_ANSWERS = 2
|
||||
private const val MAX_ANSWERS = 20
|
||||
private const val MAX_ANSWER_LENGTH = 240
|
||||
private const val MAX_SELECTIONS = 1
|
||||
|
||||
class CreatePollPresenter @AssistedInject constructor(
|
||||
private val room: MatrixRoom,
|
||||
// private val analyticsService: AnalyticsService, // TODO Polls: add analytics
|
||||
@Assisted private val navigateUp: () -> Unit,
|
||||
// private val messageComposerContext: MessageComposerContext, // TODO Polls: add analytics
|
||||
) : Presenter<CreatePollState> {
|
||||
|
||||
@AssistedFactory
|
||||
interface Factory {
|
||||
fun create(backNavigator: () -> Unit): CreatePollPresenter
|
||||
}
|
||||
|
||||
@Composable
|
||||
override fun present(): CreatePollState {
|
||||
|
||||
var question: String by rememberSaveable { mutableStateOf("") }
|
||||
var answers: List<String> by rememberSaveable() { mutableStateOf(listOf("", "")) }
|
||||
var pollKind: PollKind by rememberSaveable(saver = pollKindSaver) { mutableStateOf(PollKind.Disclosed) }
|
||||
var showConfirmation: Boolean by rememberSaveable { mutableStateOf(false) }
|
||||
|
||||
val canCreate: Boolean by remember { derivedStateOf { canCreate(question, answers) } }
|
||||
val canAddAnswer: Boolean by remember { derivedStateOf { canAddAnswer(answers) } }
|
||||
val immutableAnswers: ImmutableList<Answer> by remember { derivedStateOf { answers.toAnswers() } }
|
||||
|
||||
val scope = rememberCoroutineScope()
|
||||
|
||||
fun handleEvents(event: CreatePollEvents) {
|
||||
when (event) {
|
||||
is CreatePollEvents.Create -> scope.launch {
|
||||
if (canCreate) {
|
||||
room.createPoll(
|
||||
question = question,
|
||||
answers = answers,
|
||||
maxSelections = MAX_SELECTIONS,
|
||||
pollKind = pollKind,
|
||||
)
|
||||
// analyticsService.capture(PollCreate()) // TODO Polls: add analytics
|
||||
navigateUp()
|
||||
} else {
|
||||
Timber.d("Cannot create poll")
|
||||
}
|
||||
}
|
||||
is CreatePollEvents.AddAnswer -> {
|
||||
answers = answers + ""
|
||||
}
|
||||
is CreatePollEvents.RemoveAnswer -> {
|
||||
answers = answers.filterIndexed { index, _ -> index != event.index }
|
||||
}
|
||||
is CreatePollEvents.SetAnswer -> {
|
||||
answers = answers.toMutableList().apply {
|
||||
this[event.index] = event.text.take(MAX_ANSWER_LENGTH)
|
||||
}
|
||||
}
|
||||
is CreatePollEvents.SetPollKind -> {
|
||||
pollKind = event.pollKind
|
||||
}
|
||||
is CreatePollEvents.SetQuestion -> {
|
||||
question = event.question
|
||||
}
|
||||
is CreatePollEvents.NavBack -> {
|
||||
navigateUp()
|
||||
}
|
||||
CreatePollEvents.ConfirmNavBack -> {
|
||||
val shouldConfirm = question.isNotBlank() || answers.any { it.isNotBlank() }
|
||||
if (shouldConfirm) {
|
||||
showConfirmation = true
|
||||
} else {
|
||||
navigateUp()
|
||||
}
|
||||
}
|
||||
is CreatePollEvents.HideConfirmation -> showConfirmation = false
|
||||
}
|
||||
}
|
||||
|
||||
return CreatePollState(
|
||||
canCreate = canCreate,
|
||||
canAddAnswer = canAddAnswer,
|
||||
question = question,
|
||||
answers = immutableAnswers,
|
||||
pollKind = pollKind,
|
||||
showConfirmation = showConfirmation,
|
||||
eventSink = ::handleEvents,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private fun canCreate(
|
||||
question: String,
|
||||
answers: List<String>
|
||||
) = question.isNotBlank() && answers.size >= MIN_ANSWERS && answers.all { it.isNotBlank() }
|
||||
|
||||
private fun canAddAnswer(answers: List<String>) = answers.size < MAX_ANSWERS
|
||||
|
||||
private fun List<String>.toAnswers(): ImmutableList<Answer> {
|
||||
return map { answer ->
|
||||
Answer(
|
||||
text = answer,
|
||||
canDelete = this.size > MIN_ANSWERS,
|
||||
)
|
||||
}.toImmutableList()
|
||||
}
|
||||
|
||||
private val pollKindSaver: Saver<MutableState<PollKind>, Boolean> = Saver(
|
||||
save = {
|
||||
when (it.value) {
|
||||
PollKind.Disclosed -> false
|
||||
PollKind.Undisclosed -> true
|
||||
}
|
||||
},
|
||||
restore = {
|
||||
mutableStateOf(
|
||||
when(it) {
|
||||
true -> PollKind.Undisclosed
|
||||
else -> PollKind.Disclosed
|
||||
}
|
||||
)
|
||||
}
|
||||
)
|
||||
|
|
@ -0,0 +1,35 @@
|
|||
/*
|
||||
* Copyright (c) 2023 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 io.element.android.features.poll.impl.create
|
||||
|
||||
import io.element.android.libraries.matrix.api.poll.PollKind
|
||||
import kotlinx.collections.immutable.ImmutableList
|
||||
|
||||
data class CreatePollState(
|
||||
val canCreate: Boolean,
|
||||
val canAddAnswer: Boolean,
|
||||
val question: String,
|
||||
val answers: ImmutableList<Answer>,
|
||||
val pollKind: PollKind,
|
||||
val showConfirmation: Boolean,
|
||||
val eventSink: (CreatePollEvents) -> Unit = {},
|
||||
)
|
||||
|
||||
data class Answer(
|
||||
val text: String,
|
||||
val canDelete: Boolean,
|
||||
)
|
||||
|
|
@ -0,0 +1,124 @@
|
|||
/*
|
||||
* Copyright (c) 2023 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 io.element.android.features.poll.impl.create
|
||||
|
||||
import androidx.compose.ui.tooling.preview.PreviewParameterProvider
|
||||
import io.element.android.libraries.matrix.api.poll.PollKind
|
||||
import kotlinx.collections.immutable.persistentListOf
|
||||
|
||||
class CreatePollStateProvider : PreviewParameterProvider<CreatePollState> {
|
||||
override val values: Sequence<CreatePollState>
|
||||
get() = sequenceOf(
|
||||
CreatePollState(
|
||||
canCreate = false,
|
||||
canAddAnswer = true,
|
||||
question = "",
|
||||
answers = persistentListOf(
|
||||
Answer("", false),
|
||||
Answer("", false)
|
||||
),
|
||||
pollKind = PollKind.Disclosed,
|
||||
showConfirmation = false,
|
||||
),
|
||||
CreatePollState(
|
||||
canCreate = true,
|
||||
canAddAnswer = true,
|
||||
question = "What type of food should we have?",
|
||||
answers = persistentListOf(
|
||||
Answer("Italian \uD83C\uDDEE\uD83C\uDDF9", false),
|
||||
Answer("Chinese \uD83C\uDDE8\uD83C\uDDF3", false),
|
||||
),
|
||||
showConfirmation = false,
|
||||
pollKind = PollKind.Undisclosed,
|
||||
),
|
||||
CreatePollState(
|
||||
canCreate = true,
|
||||
canAddAnswer = true,
|
||||
question = "What type of food should we have?",
|
||||
answers = persistentListOf(
|
||||
Answer("Italian \uD83C\uDDEE\uD83C\uDDF9", false),
|
||||
Answer("Chinese \uD83C\uDDE8\uD83C\uDDF3", false),
|
||||
),
|
||||
showConfirmation = true,
|
||||
pollKind = PollKind.Undisclosed,
|
||||
),
|
||||
CreatePollState(
|
||||
canCreate = true,
|
||||
canAddAnswer = true,
|
||||
question = "What type of food should we have?",
|
||||
answers = persistentListOf(
|
||||
Answer("Italian \uD83C\uDDEE\uD83C\uDDF9", true),
|
||||
Answer("Chinese \uD83C\uDDE8\uD83C\uDDF3", true),
|
||||
Answer("Brazilian \uD83C\uDDE7\uD83C\uDDF7", true),
|
||||
Answer("French \uD83C\uDDEB\uD83C\uDDF7", true),
|
||||
),
|
||||
showConfirmation = false,
|
||||
pollKind = PollKind.Undisclosed,
|
||||
),
|
||||
CreatePollState(
|
||||
canCreate = true,
|
||||
canAddAnswer = false,
|
||||
question = "Should there be more than 20 answers?",
|
||||
answers = persistentListOf(
|
||||
Answer("1", true),
|
||||
Answer("2", true),
|
||||
Answer("3", true),
|
||||
Answer("4", true),
|
||||
Answer("5", true),
|
||||
Answer("6", true),
|
||||
Answer("7", true),
|
||||
Answer("8", true),
|
||||
Answer("9", true),
|
||||
Answer("10", true),
|
||||
Answer("11", true),
|
||||
Answer("12", true),
|
||||
Answer("13", true),
|
||||
Answer("14", true),
|
||||
Answer("15", true),
|
||||
Answer("16", true),
|
||||
Answer("17", true),
|
||||
Answer("18", true),
|
||||
Answer("19", true),
|
||||
Answer("20", true),
|
||||
),
|
||||
showConfirmation = false,
|
||||
pollKind = PollKind.Undisclosed,
|
||||
),
|
||||
CreatePollState(
|
||||
canCreate = true,
|
||||
canAddAnswer = true,
|
||||
question = "Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua." +
|
||||
" Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat. Duis aute irure dolor" +
|
||||
" in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur. Excepteur sint occaecat cupidatat non proident, sunt" +
|
||||
" in culpa qui officia deserunt mollit anim id est laborum.",
|
||||
answers = persistentListOf(
|
||||
Answer(
|
||||
"Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua." +
|
||||
" Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat. Duis a.",
|
||||
false
|
||||
),
|
||||
Answer(
|
||||
"Laboris nisi ut aliquip ex ea commodo consequat. Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore" +
|
||||
" eu fugiat nulla pariatur. Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mol.",
|
||||
false
|
||||
),
|
||||
),
|
||||
showConfirmation = false,
|
||||
pollKind = PollKind.Undisclosed,
|
||||
)
|
||||
)
|
||||
}
|
||||
|
|
@ -0,0 +1,187 @@
|
|||
/*
|
||||
* Copyright (c) 2023 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 io.element.android.features.poll.impl.create
|
||||
|
||||
import androidx.activity.compose.BackHandler
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.consumeWindowInsets
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.imePadding
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.itemsIndexed
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.Add
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.tooling.preview.PreviewParameter
|
||||
import androidx.compose.ui.unit.dp
|
||||
import io.element.android.features.poll.impl.R
|
||||
import io.element.android.libraries.designsystem.VectorIcons
|
||||
import io.element.android.libraries.designsystem.components.button.BackButton
|
||||
import io.element.android.libraries.designsystem.components.dialogs.ConfirmationDialog
|
||||
import io.element.android.libraries.designsystem.components.list.ListItemContent
|
||||
import io.element.android.libraries.designsystem.preview.DayNightPreviews
|
||||
import io.element.android.libraries.designsystem.preview.ElementPreview
|
||||
import io.element.android.libraries.designsystem.theme.aliasScreenTitle
|
||||
import io.element.android.libraries.designsystem.theme.components.HorizontalDivider
|
||||
import io.element.android.libraries.designsystem.theme.components.Icon
|
||||
import io.element.android.libraries.designsystem.theme.components.IconSource
|
||||
import io.element.android.libraries.designsystem.theme.components.ListItem
|
||||
import io.element.android.libraries.designsystem.theme.components.ListItemStyle
|
||||
import io.element.android.libraries.designsystem.theme.components.OutlinedTextField
|
||||
import io.element.android.libraries.designsystem.theme.components.Scaffold
|
||||
import io.element.android.libraries.designsystem.theme.components.Text
|
||||
import io.element.android.libraries.designsystem.theme.components.TextButton
|
||||
import io.element.android.libraries.designsystem.theme.components.TopAppBar
|
||||
import io.element.android.libraries.matrix.api.poll.PollKind
|
||||
import io.element.android.libraries.theme.ElementTheme
|
||||
import io.element.android.libraries.ui.strings.CommonStrings
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
fun CreatePollView(
|
||||
state: CreatePollState,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
val navBack = { state.eventSink(CreatePollEvents.ConfirmNavBack) }
|
||||
BackHandler(onBack = navBack)
|
||||
if (state.showConfirmation) ConfirmationDialog(
|
||||
content = stringResource(id = R.string.screen_create_poll_confirmation),
|
||||
onSubmitClicked = { state.eventSink(CreatePollEvents.NavBack) },
|
||||
onDismiss = { state.eventSink(CreatePollEvents.HideConfirmation) }
|
||||
)
|
||||
Scaffold(
|
||||
modifier = modifier,
|
||||
topBar = {
|
||||
TopAppBar(
|
||||
title = {
|
||||
Text(
|
||||
text = stringResource(id = R.string.screen_create_poll_title),
|
||||
style = ElementTheme.typography.aliasScreenTitle,
|
||||
)
|
||||
},
|
||||
navigationIcon = {
|
||||
BackButton(onClick = navBack)
|
||||
},
|
||||
actions = {
|
||||
TextButton(
|
||||
text = stringResource(id = CommonStrings.action_create),
|
||||
onClick = { state.eventSink(CreatePollEvents.Create) },
|
||||
enabled = state.canCreate,
|
||||
)
|
||||
}
|
||||
)
|
||||
},
|
||||
) { paddingValues ->
|
||||
LazyColumn(
|
||||
modifier = Modifier
|
||||
.padding(paddingValues)
|
||||
.consumeWindowInsets(paddingValues)
|
||||
.imePadding()
|
||||
.fillMaxSize(),
|
||||
) {
|
||||
item {
|
||||
Text(
|
||||
text = stringResource(id = R.string.screen_create_poll_question_desc),
|
||||
modifier = Modifier.padding(start = 32.dp),
|
||||
style = ElementTheme.typography.fontBodyMdRegular,
|
||||
)
|
||||
}
|
||||
item {
|
||||
ListItem(
|
||||
headlineContent = {
|
||||
OutlinedTextField(
|
||||
value = state.question,
|
||||
onValueChange = {
|
||||
state.eventSink(CreatePollEvents.SetQuestion(it))
|
||||
},
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
placeholder = {
|
||||
Text(text = stringResource(id = R.string.screen_create_poll_question_hint))
|
||||
},
|
||||
)
|
||||
}
|
||||
)
|
||||
}
|
||||
itemsIndexed(state.answers) { index, answer ->
|
||||
ListItem(
|
||||
headlineContent = {
|
||||
OutlinedTextField(
|
||||
value = answer.text,
|
||||
onValueChange = {
|
||||
state.eventSink(CreatePollEvents.SetAnswer(index, it))
|
||||
},
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
placeholder = {
|
||||
Text(text = stringResource(id = R.string.screen_create_poll_answer_hint, index + 1))
|
||||
},
|
||||
)
|
||||
},
|
||||
trailingContent = ListItemContent.Custom {
|
||||
Icon(
|
||||
resourceId = VectorIcons.Delete,
|
||||
contentDescription = null,
|
||||
modifier = Modifier.clickable(answer.canDelete) {
|
||||
state.eventSink(CreatePollEvents.RemoveAnswer(index))
|
||||
},
|
||||
)
|
||||
},
|
||||
style = if (answer.canDelete) ListItemStyle.Destructive else ListItemStyle.Default,
|
||||
)
|
||||
}
|
||||
if (state.canAddAnswer) {
|
||||
item {
|
||||
ListItem(
|
||||
headlineContent = { Text(text = stringResource(id = R.string.screen_create_poll_add_option_btn)) },
|
||||
leadingContent = ListItemContent.Icon(
|
||||
iconSource = IconSource.Vector(Icons.Default.Add),
|
||||
),
|
||||
style = ListItemStyle.Primary,
|
||||
onClick = { state.eventSink(CreatePollEvents.AddAnswer) },
|
||||
)
|
||||
}
|
||||
}
|
||||
item {
|
||||
HorizontalDivider()
|
||||
}
|
||||
item {
|
||||
ListItem(
|
||||
headlineContent = { Text(text = stringResource(id = R.string.screen_create_poll_anonymous_headline)) },
|
||||
supportingContent = { Text(text = stringResource(id = R.string.screen_create_poll_anonymous_desc)) },
|
||||
trailingContent = ListItemContent.Switch(
|
||||
checked = state.pollKind == PollKind.Undisclosed,
|
||||
onChange = { state.eventSink(CreatePollEvents.SetPollKind(if (it) PollKind.Undisclosed else PollKind.Disclosed)) },
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@DayNightPreviews
|
||||
@Composable
|
||||
internal fun CreatePollViewPreview(
|
||||
@PreviewParameter(CreatePollStateProvider::class) state: CreatePollState
|
||||
) = ElementPreview {
|
||||
CreatePollView(
|
||||
state = state,
|
||||
)
|
||||
}
|
||||
|
|
@ -14,33 +14,19 @@
|
|||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package io.element.android.features.poll.impl
|
||||
package io.element.android.features.poll.impl.create
|
||||
|
||||
import com.bumble.appyx.core.modality.BuildContext
|
||||
import com.bumble.appyx.core.node.Node
|
||||
import com.bumble.appyx.core.plugin.Plugin
|
||||
import com.squareup.anvil.annotations.ContributesBinding
|
||||
import io.element.android.features.poll.api.PollEntryPoint
|
||||
import io.element.android.features.poll.api.create.CreatePollEntryPoint
|
||||
import io.element.android.libraries.architecture.createNode
|
||||
import io.element.android.libraries.di.AppScope
|
||||
import javax.inject.Inject
|
||||
|
||||
@ContributesBinding(AppScope::class)
|
||||
class DefaultPollEntryPoint @Inject constructor() : PollEntryPoint {
|
||||
|
||||
override fun nodeBuilder(parentNode: Node, buildContext: BuildContext): PollEntryPoint.NodeBuilder {
|
||||
val plugins = ArrayList<Plugin>()
|
||||
|
||||
return object : PollEntryPoint.NodeBuilder {
|
||||
|
||||
override fun callback(callback: PollEntryPoint.Callback): PollEntryPoint.NodeBuilder {
|
||||
plugins += callback
|
||||
return this
|
||||
}
|
||||
|
||||
override fun build(): Node {
|
||||
return parentNode.createNode<PollFlowNode>(buildContext, plugins)
|
||||
}
|
||||
}
|
||||
class DefaultCreatePollEntryPoint @Inject constructor() : CreatePollEntryPoint {
|
||||
override fun createNode(parentNode: Node, buildContext: BuildContext): Node {
|
||||
return parentNode.createNode<CreatePollNode>(buildContext)
|
||||
}
|
||||
}
|
||||
11
features/poll/impl/src/main/res/values/localazy.xml
Normal file
11
features/poll/impl/src/main/res/values/localazy.xml
Normal file
|
|
@ -0,0 +1,11 @@
|
|||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2">
|
||||
<string name="screen_create_poll_add_option_btn">"Add option"</string>
|
||||
<string name="screen_create_poll_anonymous_desc">"Show results only after poll ends"</string>
|
||||
<string name="screen_create_poll_anonymous_headline">"Anonymous Poll"</string>
|
||||
<string name="screen_create_poll_answer_hint">"Option %1$d"</string>
|
||||
<string name="screen_create_poll_confirmation">"Are you sure you would like to go back?"</string>
|
||||
<string name="screen_create_poll_question_desc">"Question or topic"</string>
|
||||
<string name="screen_create_poll_question_hint">"What is the poll about?"</string>
|
||||
<string name="screen_create_poll_title">"Create Poll"</string>
|
||||
</resources>
|
||||
|
|
@ -0,0 +1,239 @@
|
|||
/*
|
||||
* Copyright (c) 2023 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 io.element.android.features.poll.impl.create
|
||||
|
||||
import app.cash.molecule.RecompositionMode
|
||||
import app.cash.molecule.moleculeFlow
|
||||
import app.cash.turbine.test
|
||||
import com.google.common.truth.Truth
|
||||
import io.element.android.libraries.matrix.api.poll.PollKind
|
||||
import io.element.android.libraries.matrix.test.room.CreatePollInvocation
|
||||
import io.element.android.libraries.matrix.test.room.FakeMatrixRoom
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.test.runTest
|
||||
import org.junit.Test
|
||||
|
||||
class CreatePollPresenterTest {
|
||||
|
||||
private var navUpInvocationsCount = 0
|
||||
private val fakeMatrixRoom = FakeMatrixRoom()
|
||||
// private val fakeAnalyticsService = FakeAnalyticsService() // TODO Polls: add analytics
|
||||
|
||||
private val presenter = CreatePollPresenter(
|
||||
room = fakeMatrixRoom,
|
||||
// analyticsService = fakeAnalyticsService, // TODO Polls: add analytics
|
||||
navigateUp = { navUpInvocationsCount++ },
|
||||
)
|
||||
|
||||
@Test
|
||||
fun `default state has proper default values`() = runTest {
|
||||
moleculeFlow(RecompositionMode.Immediate) {
|
||||
presenter.present()
|
||||
}.test {
|
||||
awaitItem().let {
|
||||
Truth.assertThat(it.canCreate).isEqualTo(false)
|
||||
Truth.assertThat(it.canAddAnswer).isEqualTo(true)
|
||||
Truth.assertThat(it.question).isEqualTo("")
|
||||
Truth.assertThat(it.answers).isEqualTo(listOf(Answer("", false), Answer("", false)))
|
||||
Truth.assertThat(it.pollKind).isEqualTo(PollKind.Disclosed)
|
||||
Truth.assertThat(it.showConfirmation).isEqualTo(false)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `non blank question and 2 answers are required to create a poll`() = runTest {
|
||||
moleculeFlow(RecompositionMode.Immediate) {
|
||||
presenter.present()
|
||||
}.test {
|
||||
val initial = awaitItem()
|
||||
Truth.assertThat(initial.canCreate).isEqualTo(false)
|
||||
|
||||
initial.eventSink(CreatePollEvents.SetQuestion("A question?"))
|
||||
val questionSet = awaitItem()
|
||||
Truth.assertThat(questionSet.canCreate).isEqualTo(false)
|
||||
|
||||
questionSet.eventSink(CreatePollEvents.SetAnswer(0, "Answer 1"))
|
||||
val answer1Set = awaitItem()
|
||||
Truth.assertThat(answer1Set.canCreate).isEqualTo(false)
|
||||
|
||||
answer1Set.eventSink(CreatePollEvents.SetAnswer(1, "Answer 2"))
|
||||
val answer2Set = awaitItem()
|
||||
Truth.assertThat(answer2Set.canCreate).isEqualTo(true)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `create polls sends a poll start event`() = runTest {
|
||||
moleculeFlow(RecompositionMode.Immediate) {
|
||||
presenter.present()
|
||||
}.test {
|
||||
val initial = awaitItem()
|
||||
initial.eventSink(CreatePollEvents.SetQuestion("A question?"))
|
||||
initial.eventSink(CreatePollEvents.SetAnswer(0, "Answer 1"))
|
||||
initial.eventSink(CreatePollEvents.SetAnswer(1, "Answer 2"))
|
||||
skipItems(3)
|
||||
initial.eventSink(CreatePollEvents.Create)
|
||||
delay(1) // Wait for the coroutine to finish
|
||||
Truth.assertThat(fakeMatrixRoom.createPollInvocations.size).isEqualTo(1)
|
||||
Truth.assertThat(fakeMatrixRoom.createPollInvocations.last()).isEqualTo(
|
||||
CreatePollInvocation(
|
||||
question = "A question?",
|
||||
answers = listOf("Answer 1", "Answer 2"),
|
||||
maxSelections = 1,
|
||||
pollKind = PollKind.Disclosed
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `add answer button adds an empty answer and removing it removes it`() = runTest {
|
||||
moleculeFlow(RecompositionMode.Immediate) {
|
||||
presenter.present()
|
||||
}.test {
|
||||
val initial = awaitItem()
|
||||
Truth.assertThat(initial.answers.size).isEqualTo(2)
|
||||
|
||||
initial.eventSink(CreatePollEvents.AddAnswer)
|
||||
val answerAdded = awaitItem()
|
||||
Truth.assertThat(answerAdded.answers.size).isEqualTo(3)
|
||||
Truth.assertThat(answerAdded.answers[2].text).isEqualTo("")
|
||||
|
||||
initial.eventSink(CreatePollEvents.RemoveAnswer(2))
|
||||
val answerRemoved = awaitItem()
|
||||
Truth.assertThat(answerRemoved.answers.size).isEqualTo(2)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `set question sets the question`() = runTest {
|
||||
moleculeFlow(RecompositionMode.Immediate) {
|
||||
presenter.present()
|
||||
}.test {
|
||||
val initial = awaitItem()
|
||||
initial.eventSink(CreatePollEvents.SetQuestion("A question?"))
|
||||
val questionSet = awaitItem()
|
||||
Truth.assertThat(questionSet.question).isEqualTo("A question?")
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `set poll answer sets the given poll answer`() = runTest {
|
||||
moleculeFlow(RecompositionMode.Immediate) {
|
||||
presenter.present()
|
||||
}.test {
|
||||
val initial = awaitItem()
|
||||
initial.eventSink(CreatePollEvents.SetAnswer(0, "This is answer 1"))
|
||||
val answerSet = awaitItem()
|
||||
Truth.assertThat(answerSet.answers.first().text).isEqualTo("This is answer 1")
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `set poll kind sets the poll kind`() = runTest {
|
||||
moleculeFlow(RecompositionMode.Immediate) {
|
||||
presenter.present()
|
||||
}.test {
|
||||
val initial = awaitItem()
|
||||
initial.eventSink(CreatePollEvents.SetPollKind(PollKind.Undisclosed))
|
||||
val kindSet = awaitItem()
|
||||
Truth.assertThat(kindSet.pollKind).isEqualTo(PollKind.Undisclosed)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `can add options when between 2 and 20 and then no more`() = runTest {
|
||||
moleculeFlow(RecompositionMode.Immediate) {
|
||||
presenter.present()
|
||||
}.test {
|
||||
val initial = awaitItem()
|
||||
Truth.assertThat(initial.canAddAnswer).isEqualTo(true)
|
||||
repeat(17) {
|
||||
initial.eventSink(CreatePollEvents.AddAnswer)
|
||||
Truth.assertThat(awaitItem().canAddAnswer).isEqualTo(true)
|
||||
}
|
||||
initial.eventSink(CreatePollEvents.AddAnswer)
|
||||
Truth.assertThat(awaitItem().canAddAnswer).isEqualTo(false)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `can delete option if there are more than 2`() = runTest {
|
||||
moleculeFlow(RecompositionMode.Immediate) {
|
||||
presenter.present()
|
||||
}.test {
|
||||
val initial = awaitItem()
|
||||
Truth.assertThat(initial.answers.all { it.canDelete }).isEqualTo(false)
|
||||
initial.eventSink(CreatePollEvents.AddAnswer)
|
||||
Truth.assertThat(awaitItem().answers.all { it.canDelete }).isEqualTo(true)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `option with more than 240 char is truncated`() = runTest {
|
||||
moleculeFlow(RecompositionMode.Immediate) {
|
||||
presenter.present()
|
||||
}.test {
|
||||
val initial = awaitItem()
|
||||
initial.eventSink(CreatePollEvents.SetAnswer(0, "A".repeat(241)))
|
||||
Truth.assertThat(awaitItem().answers.first().text.length).isEqualTo(240)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `navBack event calls navBack lambda`() = runTest {
|
||||
moleculeFlow(RecompositionMode.Immediate) {
|
||||
presenter.present()
|
||||
}.test {
|
||||
val initial = awaitItem()
|
||||
Truth.assertThat(navUpInvocationsCount).isEqualTo(0)
|
||||
initial.eventSink(CreatePollEvents.NavBack)
|
||||
Truth.assertThat(navUpInvocationsCount).isEqualTo(1)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `confirm nav back with blank fields calls nav back lambda`() = runTest {
|
||||
moleculeFlow(RecompositionMode.Immediate) {
|
||||
presenter.present()
|
||||
}.test {
|
||||
val initial = awaitItem()
|
||||
Truth.assertThat(navUpInvocationsCount).isEqualTo(0)
|
||||
Truth.assertThat(initial.showConfirmation).isEqualTo(false)
|
||||
initial.eventSink(CreatePollEvents.ConfirmNavBack)
|
||||
Truth.assertThat(navUpInvocationsCount).isEqualTo(1)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `confirm nav back with non blank fields shows confirmation dialog and sending hide hids it`() = runTest {
|
||||
moleculeFlow(RecompositionMode.Immediate) {
|
||||
presenter.present()
|
||||
}.test {
|
||||
val initial = awaitItem()
|
||||
initial.eventSink(CreatePollEvents.SetQuestion("Non blank"))
|
||||
Truth.assertThat(navUpInvocationsCount).isEqualTo(0)
|
||||
Truth.assertThat(awaitItem().showConfirmation).isEqualTo(false)
|
||||
initial.eventSink(CreatePollEvents.ConfirmNavBack)
|
||||
Truth.assertThat(navUpInvocationsCount).isEqualTo(0)
|
||||
Truth.assertThat(awaitItem().showConfirmation).isEqualTo(true)
|
||||
initial.eventSink(CreatePollEvents.HideConfirmation)
|
||||
Truth.assertThat(awaitItem().showConfirmation).isEqualTo(false)
|
||||
}
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue