feat: Allow user to see poll results before voting (#543)

Show a labelled checkbox to the bottom-right of polls that the user has
not voted in and that have votes. If checked the current vote tally (as
percentages) will be shown, along with a bar showing the relative value
of each option.
This commit is contained in:
Nik Clayton 2024-03-19 13:23:21 +01:00 committed by GitHub
parent b478f38e19
commit c2fc3d1f08
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
10 changed files with 238 additions and 88 deletions

View File

@ -751,7 +751,7 @@
errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~">
<location
file="src/main/res/values/strings.xml"
line="624"
line="625"
column="5"/>
</issue>
@ -1543,7 +1543,7 @@
errorLine2=" ~~~~~~~~~~~~~~~~~~~~">
<location
file="src/main/res/values/strings.xml"
line="540"
line="541"
column="13"/>
</issue>
@ -1554,7 +1554,7 @@
errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~">
<location
file="src/main/res/values/strings.xml"
line="581"
line="582"
column="13"/>
</issue>
@ -1565,7 +1565,7 @@
errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~">
<location
file="src/main/res/values/strings.xml"
line="587"
line="588"
column="13"/>
</issue>
@ -1576,7 +1576,7 @@
errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~">
<location
file="src/main/res/values/strings.xml"
line="616"
line="617"
column="13"/>
</issue>
@ -1587,7 +1587,7 @@
errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~">
<location
file="src/main/res/values/strings.xml"
line="643"
line="644"
column="13"/>
</issue>
@ -2720,6 +2720,17 @@
column="9"/>
</issue>
<issue
id="RtlSymmetry"
message="When you define `paddingEnd` you should probably also define `paddingStart` for right-to-left symmetry"
errorLine1=" android:paddingEnd=&quot;6dp&quot;"
errorLine2=" ~~~~~~~~~~~~~~~~~~">
<location
file="src/main/res/layout/item_poll.xml"
line="36"
column="9"/>
</issue>
<issue
id="RtlSymmetry"
message="When you define `paddingStart` you should probably also define `paddingEnd` for right-to-left symmetry"

View File

@ -17,7 +17,6 @@
package app.pachli.adapter
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import androidx.recyclerview.widget.RecyclerView
import app.pachli.core.activity.emojify
@ -28,7 +27,15 @@ import app.pachli.databinding.ItemPollBinding
import app.pachli.viewdata.PollOptionViewData
import app.pachli.viewdata.buildDescription
import app.pachli.viewdata.calculatePercent
import com.google.android.material.R
import com.google.android.material.color.MaterialColors
import kotlin.properties.Delegates
/** Listener for user clicks on poll items */
typealias PollOptionClickListener = (List<PollOptionViewData>) -> Unit
/** Listener for user clicks on results */
typealias ResultClickListener = () -> Unit
// This can't take [app.pachli.viewdata.PollViewData] as a parameter as it also needs to show
// data from polls that have been edited, and the "shape" of that data is quite different (no
@ -43,9 +50,9 @@ class PollAdapter(
/** True if the user can vote in this poll, false otherwise (e.g., it's from an edit) */
val enabled: Boolean = true,
/** Listener to call when the user clicks on the poll results */
private val resultClickListener: View.OnClickListener? = null,
private val resultClickListener: ResultClickListener? = null,
/** Listener to call when the user clicks on a poll option */
private val pollOptionClickListener: View.OnClickListener? = null,
private val pollOptionClickListener: PollOptionClickListener? = null,
) : RecyclerView.Adapter<BindingHolder<ItemPollBinding>>() {
/** How to display a poll */
@ -60,6 +67,14 @@ class PollAdapter(
MULTIPLE_CHOICE,
}
/**
* True if the poll's current vote details should be shown with the controls to
* vote, false otherwise. Ignored if the display maode is [DisplayMode.RESULT]
*/
var showVotes: Boolean by Delegates.observable(false) { _, _, _ ->
notifyItemRangeChanged(0, itemCount)
}
/** @return the indices of the selected options */
fun getSelected() = options.withIndex().filter { it.value.selected }.map { it.index }
@ -92,47 +107,66 @@ class PollAdapter(
checkBox.setTextColor(defaultTextColor)
}
when (displayMode) {
DisplayMode.RESULT -> {
val percent = calculatePercent(option.votesCount, votersCount, votesCount)
resultTextView.text = buildDescription(option.title, percent, option.voted, resultTextView.context)
val percent = calculatePercent(option.votesCount, votersCount, votesCount)
val level: Int
val tintColor: Int
val textColor: Int
val itemText: CharSequence
when {
displayMode == DisplayMode.RESULT && option.voted -> {
level = percent * 100
tintColor = MaterialColors.getColor(resultTextView, R.attr.colorPrimaryContainer)
textColor = MaterialColors.getColor(resultTextView, R.attr.colorOnPrimaryContainer)
itemText = buildDescription(option.title, percent, option.voted, resultTextView.context)
.emojify(emojis, resultTextView, animateEmojis)
val level = percent * 100
val optionColor: Int
val textColor: Int
// Use the "container" colours to ensure the text is visible on the container
// and on the background, per https://github.com/pachli/pachli-android/issues/85
if (option.voted) {
optionColor = MaterialColors.getColor(resultTextView, com.google.android.material.R.attr.colorPrimaryContainer)
textColor = MaterialColors.getColor(resultTextView, com.google.android.material.R.attr.colorOnPrimaryContainer)
} else {
optionColor = MaterialColors.getColor(resultTextView, com.google.android.material.R.attr.colorSecondaryContainer)
textColor = MaterialColors.getColor(resultTextView, com.google.android.material.R.attr.colorOnSecondaryContainer)
}
resultTextView.background.level = level
resultTextView.background.setTint(optionColor)
resultTextView.setTextColor(textColor)
resultTextView.setOnClickListener(resultClickListener)
}
DisplayMode.SINGLE_CHOICE -> {
radioButton.text = option.title.emojify(emojis, radioButton, animateEmojis)
radioButton.isChecked = option.selected
radioButton.setOnClickListener {
displayMode == DisplayMode.RESULT || showVotes -> {
level = percent * 100
tintColor = MaterialColors.getColor(resultTextView, R.attr.colorSecondaryContainer)
textColor = MaterialColors.getColor(resultTextView, R.attr.colorOnSecondaryContainer)
itemText = buildDescription(option.title, percent, option.voted, resultTextView.context)
.emojify(emojis, resultTextView, animateEmojis)
}
else -> {
level = 0
tintColor = MaterialColors.getColor(resultTextView, R.attr.colorSecondaryContainer)
textColor = MaterialColors.getColor(resultTextView, android.R.attr.textColorPrimary)
itemText = option.title.emojify(emojis, radioButton, animateEmojis)
}
}
when (displayMode) {
DisplayMode.RESULT -> with(resultTextView) {
text = itemText
background.level = level
background.setTint(tintColor)
setTextColor(textColor)
setOnClickListener { resultClickListener?.invoke() }
}
DisplayMode.SINGLE_CHOICE -> with(radioButton) {
isChecked = option.selected
text = itemText
background.level = level
background.setTint(tintColor)
setTextColor(textColor)
setOnClickListener {
options.forEachIndexed { index, pollOption ->
pollOption.selected = index == holder.bindingAdapterPosition
notifyItemChanged(index)
}
pollOptionClickListener?.onClick(radioButton)
pollOptionClickListener?.invoke(options)
}
}
DisplayMode.MULTIPLE_CHOICE -> {
checkBox.text = option.title.emojify(emojis, checkBox, animateEmojis)
checkBox.isChecked = option.selected
DisplayMode.MULTIPLE_CHOICE -> with(checkBox) {
isChecked = option.selected
text = itemText
background.level = level
background.setTint(tintColor)
setTextColor(textColor)
checkBox.setOnCheckedChangeListener { _, isChecked ->
options[holder.bindingAdapterPosition].selected = isChecked
pollOptionClickListener?.onClick(checkBox)
pollOptionClickListener?.invoke(options)
}
}
}

View File

@ -19,14 +19,20 @@ package app.pachli.view
import android.annotation.SuppressLint
import android.content.Context
import android.graphics.Canvas
import android.graphics.Paint
import android.graphics.Rect
import android.graphics.Typeface
import android.text.style.ReplacementSpan
import android.util.AttributeSet
import android.view.LayoutInflater
import android.view.View
import android.widget.LinearLayout
import androidx.constraintlayout.widget.ConstraintLayout
import androidx.recyclerview.widget.DefaultItemAnimator
import androidx.recyclerview.widget.LinearLayoutManager
import app.pachli.R
import app.pachli.adapter.PollAdapter
import app.pachli.adapter.PollOptionClickListener
import app.pachli.adapter.ResultClickListener
import app.pachli.core.common.extensions.hide
import app.pachli.core.common.extensions.show
import app.pachli.core.common.util.AbsoluteTimeFormatter
@ -39,6 +45,13 @@ import app.pachli.viewdata.buildDescription
import app.pachli.viewdata.calculatePercent
import java.text.NumberFormat
/**
* @param choices If null the user has clicked on the poll without voting and this
* should be treated as a navigation click. If non-null the user has voted,
* and [choices] contains the option(s) they voted for.
*/
typealias PollClickListener = (choices: List<Int>?) -> Unit
/**
* Compound view that displays [PollViewData].
*
@ -56,22 +69,12 @@ class PollView @JvmOverloads constructor(
attrs: AttributeSet? = null,
defStyleAttr: Int = 0,
defStyleRes: Int = 0,
) : LinearLayout(context, attrs, defStyleAttr, defStyleRes) {
fun interface OnClickListener {
/**
* @param choices If null the user has clicked on the poll without voting and this
* should be treated as a navigation click. If non-null the user has voted,
* and [choices] contains the option(s) they voted for.
*/
fun onClick(choices: List<Int>?)
}
) : ConstraintLayout(context, attrs, defStyleAttr, defStyleRes) {
val binding: StatusPollBinding
init {
val inflater = context.getSystemService(LayoutInflater::class.java)
binding = StatusPollBinding.inflate(inflater, this)
orientation = VERTICAL
}
fun bind(
@ -80,12 +83,12 @@ class PollView @JvmOverloads constructor(
statusDisplayOptions: StatusDisplayOptions,
numberFormat: NumberFormat,
absoluteTimeFormatter: AbsoluteTimeFormatter,
listener: OnClickListener,
listener: PollClickListener,
) {
val now = System.currentTimeMillis()
var displayMode: PollAdapter.DisplayMode = PollAdapter.DisplayMode.RESULT
var resultClickListener: View.OnClickListener? = null
var pollOptionClickListener: View.OnClickListener? = null
var resultClickListener: ResultClickListener? = null
var pollOptionClickListener: PollOptionClickListener? = null
// Translated? Create new options from old, using the translated title
val options = pollViewData.translatedPoll?.let {
@ -96,13 +99,14 @@ class PollView @JvmOverloads constructor(
val canVote = !(pollViewData.expired(now) || pollViewData.voted)
if (canVote) {
pollOptionClickListener = View.OnClickListener {
binding.statusPollButton.isEnabled = options.firstOrNull { it.selected } != null
pollOptionClickListener = {
binding.statusPollVoteButton.isEnabled = it.any { it.selected }
}
displayMode = if (pollViewData.multiple) PollAdapter.DisplayMode.MULTIPLE_CHOICE else PollAdapter.DisplayMode.SINGLE_CHOICE
} else {
resultClickListener = View.OnClickListener { listener.onClick(null) }
binding.statusPollButton.hide()
resultClickListener = { listener(null) }
binding.statusPollVoteButton.hide()
binding.statusPollShowResults.hide()
}
val adapter = PollAdapter(
@ -136,17 +140,31 @@ class PollView @JvmOverloads constructor(
if (!canVote) return
// Set up voting
binding.statusPollButton.show()
binding.statusPollButton.isEnabled = false
binding.statusPollButton.setOnClickListener {
val selected = adapter.getSelected()
if (selected.isNotEmpty()) listener.onClick(selected)
with(binding.statusPollVoteButton) {
show()
isEnabled = false
setOnClickListener {
val selected = adapter.getSelected()
if (selected.isNotEmpty()) listener(selected)
}
}
// Set up showing/hiding votes
if (pollViewData.votesCount > 0) {
with(binding.statusPollShowResults) {
show()
isChecked = adapter.showVotes
setOnCheckedChangeListener { _, isChecked ->
adapter.showVotes = isChecked
}
}
}
}
fun hide() {
binding.statusPollOptions.hide()
binding.statusPollButton.hide()
binding.statusPollVoteButton.hide()
binding.statusPollShowResults.hide()
binding.statusPollDescription.hide()
}
@ -222,3 +240,42 @@ class PollView @JvmOverloads constructor(
return context.getString(R.string.description_poll, *args)
}
}
/**
* Span to show vote percentages inline in a poll.
*
* Shows the text at 80% of normal size and bold. Text is right-justified in a space guaranteed
* to be large enough to accomodate "100%".
*/
class VotePercentSpan : ReplacementSpan() {
override fun getSize(paint: Paint, text: CharSequence?, start: Int, end: Int, fm: Paint.FontMetricsInt?): Int {
paint.textSize *= 0.8f
return paint.measureText(TEMPLATE).toInt()
}
override fun draw(canvas: Canvas, text: CharSequence?, start: Int, end: Int, x: Float, top: Int, y: Int, bottom: Int, paint: Paint) {
text ?: return
val actualText = text.subSequence(start, end).toString()
paint.textSize *= 0.8f
paint.typeface = Typeface.create(paint.typeface, Typeface.BOLD)
// Compute an x-offset for the text so it will be right aligned
val actualTextWidth = paint.measureText(actualText)
val spanWidth = paint.measureText(TEMPLATE)
val xOffset = spanWidth - actualTextWidth
// Compute a new y value so the text will be centre-aligned within the span
val textBounds = Rect()
paint.getTextBounds(actualText, 0, actualText.length, textBounds)
val spanHeight = (bottom - top)
val newY = (spanHeight / 2) + (textBounds.height() / 2)
canvas.drawText(actualText, start, end, x + xOffset, newY.toFloat(), paint)
}
companion object {
/** Span will be sized to be large enough for this text */
private const val TEMPLATE = "100%"
}
}

View File

@ -19,11 +19,12 @@ package app.pachli.viewdata
import android.content.Context
import android.text.SpannableStringBuilder
import android.text.Spanned
import androidx.core.text.parseAsHtml
import android.text.Spanned.SPAN_EXCLUSIVE_EXCLUSIVE
import app.pachli.R
import app.pachli.core.network.model.Poll
import app.pachli.core.network.model.PollOption
import app.pachli.core.network.model.TranslatedPoll
import app.pachli.view.VotePercentSpan
import java.util.Date
import kotlin.math.roundToInt
@ -86,11 +87,11 @@ fun calculatePercent(fraction: Int, totalVoters: Int?, totalVotes: Int): Int {
}
fun buildDescription(title: String, percent: Int, voted: Boolean, context: Context): Spanned {
val builder = SpannableStringBuilder(context.getString(R.string.poll_percent_format, percent).parseAsHtml())
val percentStr = context.getString(R.string.poll_percent_format, percent)
val builder = SpannableStringBuilder(percentStr)
builder.setSpan(VotePercentSpan(), 0, percentStr.length, SPAN_EXCLUSIVE_EXCLUSIVE)
if (voted) {
builder.append("")
} else {
builder.append(" ")
}
return builder.append(title)
}

View File

@ -0,0 +1,22 @@
<?xml version="1.0" encoding="utf-8"?><!--
~ Copyright 2024 Pachli Association
~
~ This file is a part of Pachli.
~
~ This program is free software; you can redistribute it and/or modify it under the terms of the
~ GNU General Public License as published by the Free Software Foundation; either version 3 of the
~ License, or (at your option) any later version.
~
~ Pachli is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even
~ the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General
~ Public License for more details.
~
~ You should have received a copy of the GNU General Public License along with Pachli; if not,
~ see <http://www.gnu.org/licenses>.
-->
<inset
xmlns:android="http://schemas.android.com/apk/res/android"
android:drawable="@drawable/poll_option_background"
android:insetTop="3dp"
android:insetBottom="3dp" />

View File

@ -9,7 +9,8 @@
android:id="@+id/status_poll_option_result"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="6dp"
android:layout_marginTop="2dp"
android:layout_marginBottom="4dp"
android:background="@drawable/poll_option_background"
android:maxLines="3"
android:ellipsize="end"
@ -20,24 +21,30 @@
android:textAlignment="viewStart"
android:textColor="?colorOnSecondary"
android:textSize="?attr/status_text_medium"
tools:visibility="gone"
tools:text="40%" />
<RadioButton
android:id="@+id/status_poll_radio_button"
android:layout_width="wrap_content"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:textColor="?android:attr/textColorPrimary"
android:textSize="?attr/status_text_medium"
app:buttonTint="@color/compound_button_color"
android:background="@drawable/poll_option_background_inset"
android:paddingTop="2dp"
android:paddingEnd="6dp"
tools:text="Option 1" />
<CheckBox
android:id="@+id/status_poll_checkbox"
android:layout_width="wrap_content"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:textColor="?android:attr/textColorPrimary"
android:textSize="?attr/status_text_medium"
app:buttonTint="@color/compound_button_color"
android:background="@drawable/poll_option_background_inset"
tools:visibility="gone"
tools:text="Option 1" />
</FrameLayout>

View File

@ -213,7 +213,7 @@
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="@id/status_display_name"
app:layout_constraintTop_toBottomOf="@id/status_media_preview_container"
tools:visibility="gone" />
tools:visibility="visible" />
<TextView
android:id="@+id/translationProvider"

View File

@ -15,38 +15,55 @@
~ see <http://www.gnu.org/licenses>.
-->
<merge
xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools">
<merge 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"
tools:parentTag="androidx.constraintlayout.widget.ConstraintLayout">
<androidx.recyclerview.widget.RecyclerView
android:id="@+id/status_poll_options"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:nestedScrollingEnabled="false" />
android:nestedScrollingEnabled="false"
tools:listitem="@layout/item_poll"
tools:itemCount="4" />
<Button
android:id="@+id/status_poll_button"
android:id="@+id/status_poll_vote_button"
style="@style/AppButton.Outlined"
android:layout_width="wrap_content"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_marginTop="4dp"
android:gravity="center"
android:minWidth="150dp"
android:minHeight="0dp"
android:paddingLeft="16dp"
android:paddingTop="4dp"
android:paddingRight="16dp"
android:paddingBottom="4dp"
android:text="@string/poll_vote"
android:textSize="?attr/status_text_medium" />
android:textSize="?attr/status_text_medium"
app:layout_constraintEnd_toStartOf="@+id/status_poll_show_results"
app:layout_constraintHorizontal_chainStyle="spread_inside"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@+id/status_poll_options"
app:layout_constraintWidth_max="150dp" />
<CheckBox
android:id="@+id/status_poll_show_results"
android:layout_width="wrap_content"
android:layout_height="0dp"
android:text="@string/poll_show_votes"
android:visibility="gone"
app:layout_constraintBottom_toBottomOf="@+id/status_poll_vote_button"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toEndOf="@id/status_poll_vote_button"
app:layout_constraintTop_toTopOf="@+id/status_poll_vote_button"
tools:visibility="visible" />
<TextView
android:id="@+id/status_poll_description"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginTop="6dp"
android:textSize="?attr/status_text_medium"
android:layout_marginTop="8dp"
android:textIsSelectable="true"
android:textSize="?attr/status_text_medium"
app:layout_constraintTop_toBottomOf="@id/status_poll_vote_button"
tools:text="7 votes • 7 hours remaining" />
</merge>

View File

@ -229,7 +229,7 @@
<item>31536000</item>
</integer-array>
<string name="poll_percent_format"><!-- 15% --> &lt;b>%1$d%%&lt;/b></string>
<string name="poll_percent_format"><!-- 15% -->%1$d%%</string>
<string-array name="mute_duration_names">
<item>@string/duration_indefinite</item>

View File

@ -513,6 +513,7 @@
<string name="poll_info_time_absolute">ends at %s</string>
<string name="poll_info_closed">closed</string>
<string name="poll_vote">Vote</string>
<string name="poll_show_votes">Show votes</string>
<string name="poll_ended_voted">A poll you have voted in has ended</string>
<string name="poll_ended_created">A poll you created has ended</string>
<!--These are for timestamps on polls -->