Skip to content

Commit

Permalink
feat: Allow user to see poll results before voting (#543)
Browse files Browse the repository at this point in the history
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.
  • Loading branch information
nikclayton authored Mar 19, 2024
1 parent b478f38 commit c2fc3d1
Show file tree
Hide file tree
Showing 10 changed files with 237 additions and 87 deletions.
23 changes: 17 additions & 6 deletions app/lint-baseline.xml
Original file line number Diff line number Diff line change
Expand Up @@ -751,7 +751,7 @@
errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~">
<location
file="src/main/res/values/strings.xml"
line="624"
line="625"
column="5"/>
</issue>

Expand Down Expand Up @@ -1543,7 +1543,7 @@
errorLine2=" ~~~~~~~~~~~~~~~~~~~~">
<location
file="src/main/res/values/strings.xml"
line="540"
line="541"
column="13"/>
</issue>

Expand All @@ -1554,7 +1554,7 @@
errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~">
<location
file="src/main/res/values/strings.xml"
line="581"
line="582"
column="13"/>
</issue>

Expand All @@ -1565,7 +1565,7 @@
errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~">
<location
file="src/main/res/values/strings.xml"
line="587"
line="588"
column="13"/>
</issue>

Expand All @@ -1576,7 +1576,7 @@
errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~">
<location
file="src/main/res/values/strings.xml"
line="616"
line="617"
column="13"/>
</issue>

Expand All @@ -1587,7 +1587,7 @@
errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~">
<location
file="src/main/res/values/strings.xml"
line="643"
line="644"
column="13"/>
</issue>

Expand Down Expand Up @@ -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"
Expand Down
100 changes: 67 additions & 33 deletions app/src/main/java/app/pachli/adapter/PollAdapter.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand All @@ -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 */
Expand All @@ -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 }

Expand Down Expand Up @@ -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)
}
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)
}
}

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)
when (displayMode) {
DisplayMode.RESULT -> with(resultTextView) {
text = itemText
background.level = level
background.setTint(tintColor)
setTextColor(textColor)
setOnClickListener { resultClickListener?.invoke() }
}
DisplayMode.SINGLE_CHOICE -> {
radioButton.text = option.title.emojify(emojis, radioButton, animateEmojis)
radioButton.isChecked = option.selected
radioButton.setOnClickListener {
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)
}
}
}
Expand Down
109 changes: 83 additions & 26 deletions app/src/main/java/app/pachli/view/PollView.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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].
*
Expand All @@ -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(
Expand All @@ -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 {
Expand All @@ -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(
Expand Down Expand Up @@ -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()
}

Expand Down Expand Up @@ -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%"
}
}
Loading

0 comments on commit c2fc3d1

Please sign in to comment.