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:
parent
b478f38e19
commit
c2fc3d1f08
|
@ -751,7 +751,7 @@
|
||||||
errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~">
|
errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~">
|
||||||
<location
|
<location
|
||||||
file="src/main/res/values/strings.xml"
|
file="src/main/res/values/strings.xml"
|
||||||
line="624"
|
line="625"
|
||||||
column="5"/>
|
column="5"/>
|
||||||
</issue>
|
</issue>
|
||||||
|
|
||||||
|
@ -1543,7 +1543,7 @@
|
||||||
errorLine2=" ~~~~~~~~~~~~~~~~~~~~">
|
errorLine2=" ~~~~~~~~~~~~~~~~~~~~">
|
||||||
<location
|
<location
|
||||||
file="src/main/res/values/strings.xml"
|
file="src/main/res/values/strings.xml"
|
||||||
line="540"
|
line="541"
|
||||||
column="13"/>
|
column="13"/>
|
||||||
</issue>
|
</issue>
|
||||||
|
|
||||||
|
@ -1554,7 +1554,7 @@
|
||||||
errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~">
|
errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~">
|
||||||
<location
|
<location
|
||||||
file="src/main/res/values/strings.xml"
|
file="src/main/res/values/strings.xml"
|
||||||
line="581"
|
line="582"
|
||||||
column="13"/>
|
column="13"/>
|
||||||
</issue>
|
</issue>
|
||||||
|
|
||||||
|
@ -1565,7 +1565,7 @@
|
||||||
errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~">
|
errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~">
|
||||||
<location
|
<location
|
||||||
file="src/main/res/values/strings.xml"
|
file="src/main/res/values/strings.xml"
|
||||||
line="587"
|
line="588"
|
||||||
column="13"/>
|
column="13"/>
|
||||||
</issue>
|
</issue>
|
||||||
|
|
||||||
|
@ -1576,7 +1576,7 @@
|
||||||
errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~">
|
errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~">
|
||||||
<location
|
<location
|
||||||
file="src/main/res/values/strings.xml"
|
file="src/main/res/values/strings.xml"
|
||||||
line="616"
|
line="617"
|
||||||
column="13"/>
|
column="13"/>
|
||||||
</issue>
|
</issue>
|
||||||
|
|
||||||
|
@ -1587,7 +1587,7 @@
|
||||||
errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~">
|
errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~">
|
||||||
<location
|
<location
|
||||||
file="src/main/res/values/strings.xml"
|
file="src/main/res/values/strings.xml"
|
||||||
line="643"
|
line="644"
|
||||||
column="13"/>
|
column="13"/>
|
||||||
</issue>
|
</issue>
|
||||||
|
|
||||||
|
@ -2720,6 +2720,17 @@
|
||||||
column="9"/>
|
column="9"/>
|
||||||
</issue>
|
</issue>
|
||||||
|
|
||||||
|
<issue
|
||||||
|
id="RtlSymmetry"
|
||||||
|
message="When you define `paddingEnd` you should probably also define `paddingStart` for right-to-left symmetry"
|
||||||
|
errorLine1=" android:paddingEnd="6dp""
|
||||||
|
errorLine2=" ~~~~~~~~~~~~~~~~~~">
|
||||||
|
<location
|
||||||
|
file="src/main/res/layout/item_poll.xml"
|
||||||
|
line="36"
|
||||||
|
column="9"/>
|
||||||
|
</issue>
|
||||||
|
|
||||||
<issue
|
<issue
|
||||||
id="RtlSymmetry"
|
id="RtlSymmetry"
|
||||||
message="When you define `paddingStart` you should probably also define `paddingEnd` for right-to-left symmetry"
|
message="When you define `paddingStart` you should probably also define `paddingEnd` for right-to-left symmetry"
|
||||||
|
|
|
@ -17,7 +17,6 @@
|
||||||
package app.pachli.adapter
|
package app.pachli.adapter
|
||||||
|
|
||||||
import android.view.LayoutInflater
|
import android.view.LayoutInflater
|
||||||
import android.view.View
|
|
||||||
import android.view.ViewGroup
|
import android.view.ViewGroup
|
||||||
import androidx.recyclerview.widget.RecyclerView
|
import androidx.recyclerview.widget.RecyclerView
|
||||||
import app.pachli.core.activity.emojify
|
import app.pachli.core.activity.emojify
|
||||||
|
@ -28,7 +27,15 @@ import app.pachli.databinding.ItemPollBinding
|
||||||
import app.pachli.viewdata.PollOptionViewData
|
import app.pachli.viewdata.PollOptionViewData
|
||||||
import app.pachli.viewdata.buildDescription
|
import app.pachli.viewdata.buildDescription
|
||||||
import app.pachli.viewdata.calculatePercent
|
import app.pachli.viewdata.calculatePercent
|
||||||
|
import com.google.android.material.R
|
||||||
import com.google.android.material.color.MaterialColors
|
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
|
// 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
|
// 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) */
|
/** True if the user can vote in this poll, false otherwise (e.g., it's from an edit) */
|
||||||
val enabled: Boolean = true,
|
val enabled: Boolean = true,
|
||||||
/** Listener to call when the user clicks on the poll results */
|
/** 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 */
|
/** 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>>() {
|
) : RecyclerView.Adapter<BindingHolder<ItemPollBinding>>() {
|
||||||
|
|
||||||
/** How to display a poll */
|
/** How to display a poll */
|
||||||
|
@ -60,6 +67,14 @@ class PollAdapter(
|
||||||
MULTIPLE_CHOICE,
|
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 */
|
/** @return the indices of the selected options */
|
||||||
fun getSelected() = options.withIndex().filter { it.value.selected }.map { it.index }
|
fun getSelected() = options.withIndex().filter { it.value.selected }.map { it.index }
|
||||||
|
|
||||||
|
@ -92,47 +107,66 @@ class PollAdapter(
|
||||||
checkBox.setTextColor(defaultTextColor)
|
checkBox.setTextColor(defaultTextColor)
|
||||||
}
|
}
|
||||||
|
|
||||||
when (displayMode) {
|
|
||||||
DisplayMode.RESULT -> {
|
|
||||||
val percent = calculatePercent(option.votesCount, votersCount, votesCount)
|
val percent = calculatePercent(option.votesCount, votersCount, votesCount)
|
||||||
resultTextView.text = buildDescription(option.title, percent, option.voted, resultTextView.context)
|
val level: Int
|
||||||
.emojify(emojis, resultTextView, animateEmojis)
|
val tintColor: Int
|
||||||
|
|
||||||
val level = percent * 100
|
|
||||||
val optionColor: Int
|
|
||||||
val textColor: Int
|
val textColor: Int
|
||||||
// Use the "container" colours to ensure the text is visible on the container
|
val itemText: CharSequence
|
||||||
// and on the background, per https://github.com/pachli/pachli-android/issues/85
|
|
||||||
if (option.voted) {
|
when {
|
||||||
optionColor = MaterialColors.getColor(resultTextView, com.google.android.material.R.attr.colorPrimaryContainer)
|
displayMode == DisplayMode.RESULT && option.voted -> {
|
||||||
textColor = MaterialColors.getColor(resultTextView, com.google.android.material.R.attr.colorOnPrimaryContainer)
|
level = percent * 100
|
||||||
} else {
|
tintColor = MaterialColors.getColor(resultTextView, R.attr.colorPrimaryContainer)
|
||||||
optionColor = MaterialColors.getColor(resultTextView, com.google.android.material.R.attr.colorSecondaryContainer)
|
textColor = MaterialColors.getColor(resultTextView, R.attr.colorOnPrimaryContainer)
|
||||||
textColor = MaterialColors.getColor(resultTextView, com.google.android.material.R.attr.colorOnSecondaryContainer)
|
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)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
resultTextView.background.level = level
|
when (displayMode) {
|
||||||
resultTextView.background.setTint(optionColor)
|
DisplayMode.RESULT -> with(resultTextView) {
|
||||||
resultTextView.setTextColor(textColor)
|
text = itemText
|
||||||
resultTextView.setOnClickListener(resultClickListener)
|
background.level = level
|
||||||
|
background.setTint(tintColor)
|
||||||
|
setTextColor(textColor)
|
||||||
|
setOnClickListener { resultClickListener?.invoke() }
|
||||||
}
|
}
|
||||||
DisplayMode.SINGLE_CHOICE -> {
|
DisplayMode.SINGLE_CHOICE -> with(radioButton) {
|
||||||
radioButton.text = option.title.emojify(emojis, radioButton, animateEmojis)
|
isChecked = option.selected
|
||||||
radioButton.isChecked = option.selected
|
text = itemText
|
||||||
radioButton.setOnClickListener {
|
background.level = level
|
||||||
|
background.setTint(tintColor)
|
||||||
|
setTextColor(textColor)
|
||||||
|
setOnClickListener {
|
||||||
options.forEachIndexed { index, pollOption ->
|
options.forEachIndexed { index, pollOption ->
|
||||||
pollOption.selected = index == holder.bindingAdapterPosition
|
pollOption.selected = index == holder.bindingAdapterPosition
|
||||||
notifyItemChanged(index)
|
notifyItemChanged(index)
|
||||||
}
|
}
|
||||||
pollOptionClickListener?.onClick(radioButton)
|
pollOptionClickListener?.invoke(options)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
DisplayMode.MULTIPLE_CHOICE -> {
|
DisplayMode.MULTIPLE_CHOICE -> with(checkBox) {
|
||||||
checkBox.text = option.title.emojify(emojis, checkBox, animateEmojis)
|
isChecked = option.selected
|
||||||
checkBox.isChecked = option.selected
|
text = itemText
|
||||||
|
background.level = level
|
||||||
|
background.setTint(tintColor)
|
||||||
|
setTextColor(textColor)
|
||||||
checkBox.setOnCheckedChangeListener { _, isChecked ->
|
checkBox.setOnCheckedChangeListener { _, isChecked ->
|
||||||
options[holder.bindingAdapterPosition].selected = isChecked
|
options[holder.bindingAdapterPosition].selected = isChecked
|
||||||
pollOptionClickListener?.onClick(checkBox)
|
pollOptionClickListener?.invoke(options)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -19,14 +19,20 @@ package app.pachli.view
|
||||||
|
|
||||||
import android.annotation.SuppressLint
|
import android.annotation.SuppressLint
|
||||||
import android.content.Context
|
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.util.AttributeSet
|
||||||
import android.view.LayoutInflater
|
import android.view.LayoutInflater
|
||||||
import android.view.View
|
import androidx.constraintlayout.widget.ConstraintLayout
|
||||||
import android.widget.LinearLayout
|
|
||||||
import androidx.recyclerview.widget.DefaultItemAnimator
|
import androidx.recyclerview.widget.DefaultItemAnimator
|
||||||
import androidx.recyclerview.widget.LinearLayoutManager
|
import androidx.recyclerview.widget.LinearLayoutManager
|
||||||
import app.pachli.R
|
import app.pachli.R
|
||||||
import app.pachli.adapter.PollAdapter
|
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.hide
|
||||||
import app.pachli.core.common.extensions.show
|
import app.pachli.core.common.extensions.show
|
||||||
import app.pachli.core.common.util.AbsoluteTimeFormatter
|
import app.pachli.core.common.util.AbsoluteTimeFormatter
|
||||||
|
@ -39,6 +45,13 @@ import app.pachli.viewdata.buildDescription
|
||||||
import app.pachli.viewdata.calculatePercent
|
import app.pachli.viewdata.calculatePercent
|
||||||
import java.text.NumberFormat
|
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].
|
* Compound view that displays [PollViewData].
|
||||||
*
|
*
|
||||||
|
@ -56,22 +69,12 @@ class PollView @JvmOverloads constructor(
|
||||||
attrs: AttributeSet? = null,
|
attrs: AttributeSet? = null,
|
||||||
defStyleAttr: Int = 0,
|
defStyleAttr: Int = 0,
|
||||||
defStyleRes: Int = 0,
|
defStyleRes: Int = 0,
|
||||||
) : LinearLayout(context, attrs, defStyleAttr, defStyleRes) {
|
) : ConstraintLayout(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>?)
|
|
||||||
}
|
|
||||||
|
|
||||||
val binding: StatusPollBinding
|
val binding: StatusPollBinding
|
||||||
|
|
||||||
init {
|
init {
|
||||||
val inflater = context.getSystemService(LayoutInflater::class.java)
|
val inflater = context.getSystemService(LayoutInflater::class.java)
|
||||||
binding = StatusPollBinding.inflate(inflater, this)
|
binding = StatusPollBinding.inflate(inflater, this)
|
||||||
orientation = VERTICAL
|
|
||||||
}
|
}
|
||||||
|
|
||||||
fun bind(
|
fun bind(
|
||||||
|
@ -80,12 +83,12 @@ class PollView @JvmOverloads constructor(
|
||||||
statusDisplayOptions: StatusDisplayOptions,
|
statusDisplayOptions: StatusDisplayOptions,
|
||||||
numberFormat: NumberFormat,
|
numberFormat: NumberFormat,
|
||||||
absoluteTimeFormatter: AbsoluteTimeFormatter,
|
absoluteTimeFormatter: AbsoluteTimeFormatter,
|
||||||
listener: OnClickListener,
|
listener: PollClickListener,
|
||||||
) {
|
) {
|
||||||
val now = System.currentTimeMillis()
|
val now = System.currentTimeMillis()
|
||||||
var displayMode: PollAdapter.DisplayMode = PollAdapter.DisplayMode.RESULT
|
var displayMode: PollAdapter.DisplayMode = PollAdapter.DisplayMode.RESULT
|
||||||
var resultClickListener: View.OnClickListener? = null
|
var resultClickListener: ResultClickListener? = null
|
||||||
var pollOptionClickListener: View.OnClickListener? = null
|
var pollOptionClickListener: PollOptionClickListener? = null
|
||||||
|
|
||||||
// Translated? Create new options from old, using the translated title
|
// Translated? Create new options from old, using the translated title
|
||||||
val options = pollViewData.translatedPoll?.let {
|
val options = pollViewData.translatedPoll?.let {
|
||||||
|
@ -96,13 +99,14 @@ class PollView @JvmOverloads constructor(
|
||||||
|
|
||||||
val canVote = !(pollViewData.expired(now) || pollViewData.voted)
|
val canVote = !(pollViewData.expired(now) || pollViewData.voted)
|
||||||
if (canVote) {
|
if (canVote) {
|
||||||
pollOptionClickListener = View.OnClickListener {
|
pollOptionClickListener = {
|
||||||
binding.statusPollButton.isEnabled = options.firstOrNull { it.selected } != null
|
binding.statusPollVoteButton.isEnabled = it.any { it.selected }
|
||||||
}
|
}
|
||||||
displayMode = if (pollViewData.multiple) PollAdapter.DisplayMode.MULTIPLE_CHOICE else PollAdapter.DisplayMode.SINGLE_CHOICE
|
displayMode = if (pollViewData.multiple) PollAdapter.DisplayMode.MULTIPLE_CHOICE else PollAdapter.DisplayMode.SINGLE_CHOICE
|
||||||
} else {
|
} else {
|
||||||
resultClickListener = View.OnClickListener { listener.onClick(null) }
|
resultClickListener = { listener(null) }
|
||||||
binding.statusPollButton.hide()
|
binding.statusPollVoteButton.hide()
|
||||||
|
binding.statusPollShowResults.hide()
|
||||||
}
|
}
|
||||||
|
|
||||||
val adapter = PollAdapter(
|
val adapter = PollAdapter(
|
||||||
|
@ -136,17 +140,31 @@ class PollView @JvmOverloads constructor(
|
||||||
if (!canVote) return
|
if (!canVote) return
|
||||||
|
|
||||||
// Set up voting
|
// Set up voting
|
||||||
binding.statusPollButton.show()
|
with(binding.statusPollVoteButton) {
|
||||||
binding.statusPollButton.isEnabled = false
|
show()
|
||||||
binding.statusPollButton.setOnClickListener {
|
isEnabled = false
|
||||||
|
setOnClickListener {
|
||||||
val selected = adapter.getSelected()
|
val selected = adapter.getSelected()
|
||||||
if (selected.isNotEmpty()) listener.onClick(selected)
|
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() {
|
fun hide() {
|
||||||
binding.statusPollOptions.hide()
|
binding.statusPollOptions.hide()
|
||||||
binding.statusPollButton.hide()
|
binding.statusPollVoteButton.hide()
|
||||||
|
binding.statusPollShowResults.hide()
|
||||||
binding.statusPollDescription.hide()
|
binding.statusPollDescription.hide()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -222,3 +240,42 @@ class PollView @JvmOverloads constructor(
|
||||||
return context.getString(R.string.description_poll, *args)
|
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%"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
@ -19,11 +19,12 @@ package app.pachli.viewdata
|
||||||
import android.content.Context
|
import android.content.Context
|
||||||
import android.text.SpannableStringBuilder
|
import android.text.SpannableStringBuilder
|
||||||
import android.text.Spanned
|
import android.text.Spanned
|
||||||
import androidx.core.text.parseAsHtml
|
import android.text.Spanned.SPAN_EXCLUSIVE_EXCLUSIVE
|
||||||
import app.pachli.R
|
import app.pachli.R
|
||||||
import app.pachli.core.network.model.Poll
|
import app.pachli.core.network.model.Poll
|
||||||
import app.pachli.core.network.model.PollOption
|
import app.pachli.core.network.model.PollOption
|
||||||
import app.pachli.core.network.model.TranslatedPoll
|
import app.pachli.core.network.model.TranslatedPoll
|
||||||
|
import app.pachli.view.VotePercentSpan
|
||||||
import java.util.Date
|
import java.util.Date
|
||||||
import kotlin.math.roundToInt
|
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 {
|
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) {
|
if (voted) {
|
||||||
builder.append(" ✓ ")
|
builder.append(" ✓ ")
|
||||||
} else {
|
|
||||||
builder.append(" ")
|
|
||||||
}
|
}
|
||||||
return builder.append(title)
|
return builder.append(title)
|
||||||
}
|
}
|
||||||
|
|
|
@ -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" />
|
|
@ -9,7 +9,8 @@
|
||||||
android:id="@+id/status_poll_option_result"
|
android:id="@+id/status_poll_option_result"
|
||||||
android:layout_width="match_parent"
|
android:layout_width="match_parent"
|
||||||
android:layout_height="wrap_content"
|
android:layout_height="wrap_content"
|
||||||
android:layout_marginTop="6dp"
|
android:layout_marginTop="2dp"
|
||||||
|
android:layout_marginBottom="4dp"
|
||||||
android:background="@drawable/poll_option_background"
|
android:background="@drawable/poll_option_background"
|
||||||
android:maxLines="3"
|
android:maxLines="3"
|
||||||
android:ellipsize="end"
|
android:ellipsize="end"
|
||||||
|
@ -20,24 +21,30 @@
|
||||||
android:textAlignment="viewStart"
|
android:textAlignment="viewStart"
|
||||||
android:textColor="?colorOnSecondary"
|
android:textColor="?colorOnSecondary"
|
||||||
android:textSize="?attr/status_text_medium"
|
android:textSize="?attr/status_text_medium"
|
||||||
|
tools:visibility="gone"
|
||||||
tools:text="40%" />
|
tools:text="40%" />
|
||||||
|
|
||||||
<RadioButton
|
<RadioButton
|
||||||
android:id="@+id/status_poll_radio_button"
|
android:id="@+id/status_poll_radio_button"
|
||||||
android:layout_width="wrap_content"
|
android:layout_width="match_parent"
|
||||||
android:layout_height="wrap_content"
|
android:layout_height="wrap_content"
|
||||||
android:textColor="?android:attr/textColorPrimary"
|
android:textColor="?android:attr/textColorPrimary"
|
||||||
android:textSize="?attr/status_text_medium"
|
android:textSize="?attr/status_text_medium"
|
||||||
app:buttonTint="@color/compound_button_color"
|
app:buttonTint="@color/compound_button_color"
|
||||||
|
android:background="@drawable/poll_option_background_inset"
|
||||||
|
android:paddingTop="2dp"
|
||||||
|
android:paddingEnd="6dp"
|
||||||
tools:text="Option 1" />
|
tools:text="Option 1" />
|
||||||
|
|
||||||
<CheckBox
|
<CheckBox
|
||||||
android:id="@+id/status_poll_checkbox"
|
android:id="@+id/status_poll_checkbox"
|
||||||
android:layout_width="wrap_content"
|
android:layout_width="match_parent"
|
||||||
android:layout_height="wrap_content"
|
android:layout_height="wrap_content"
|
||||||
android:textColor="?android:attr/textColorPrimary"
|
android:textColor="?android:attr/textColorPrimary"
|
||||||
android:textSize="?attr/status_text_medium"
|
android:textSize="?attr/status_text_medium"
|
||||||
app:buttonTint="@color/compound_button_color"
|
app:buttonTint="@color/compound_button_color"
|
||||||
|
android:background="@drawable/poll_option_background_inset"
|
||||||
|
tools:visibility="gone"
|
||||||
tools:text="Option 1" />
|
tools:text="Option 1" />
|
||||||
|
|
||||||
</FrameLayout>
|
</FrameLayout>
|
||||||
|
|
|
@ -213,7 +213,7 @@
|
||||||
app:layout_constraintEnd_toEndOf="parent"
|
app:layout_constraintEnd_toEndOf="parent"
|
||||||
app:layout_constraintStart_toStartOf="@id/status_display_name"
|
app:layout_constraintStart_toStartOf="@id/status_display_name"
|
||||||
app:layout_constraintTop_toBottomOf="@id/status_media_preview_container"
|
app:layout_constraintTop_toBottomOf="@id/status_media_preview_container"
|
||||||
tools:visibility="gone" />
|
tools:visibility="visible" />
|
||||||
|
|
||||||
<TextView
|
<TextView
|
||||||
android:id="@+id/translationProvider"
|
android:id="@+id/translationProvider"
|
||||||
|
|
|
@ -15,38 +15,55 @@
|
||||||
~ see <http://www.gnu.org/licenses>.
|
~ see <http://www.gnu.org/licenses>.
|
||||||
-->
|
-->
|
||||||
|
|
||||||
<merge
|
<merge xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
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">
|
xmlns:tools="http://schemas.android.com/tools"
|
||||||
|
tools:parentTag="androidx.constraintlayout.widget.ConstraintLayout">
|
||||||
|
|
||||||
<androidx.recyclerview.widget.RecyclerView
|
<androidx.recyclerview.widget.RecyclerView
|
||||||
android:id="@+id/status_poll_options"
|
android:id="@+id/status_poll_options"
|
||||||
android:layout_width="match_parent"
|
android:layout_width="match_parent"
|
||||||
android:layout_height="wrap_content"
|
android:layout_height="wrap_content"
|
||||||
android:nestedScrollingEnabled="false" />
|
android:nestedScrollingEnabled="false"
|
||||||
|
tools:listitem="@layout/item_poll"
|
||||||
|
tools:itemCount="4" />
|
||||||
|
|
||||||
<Button
|
<Button
|
||||||
android:id="@+id/status_poll_button"
|
android:id="@+id/status_poll_vote_button"
|
||||||
style="@style/AppButton.Outlined"
|
style="@style/AppButton.Outlined"
|
||||||
android:layout_width="wrap_content"
|
android:layout_width="0dp"
|
||||||
android:layout_height="wrap_content"
|
android:layout_height="wrap_content"
|
||||||
android:layout_marginTop="4dp"
|
|
||||||
android:gravity="center"
|
|
||||||
android:minWidth="150dp"
|
|
||||||
android:minHeight="0dp"
|
|
||||||
android:paddingLeft="16dp"
|
android:paddingLeft="16dp"
|
||||||
android:paddingTop="4dp"
|
android:paddingTop="4dp"
|
||||||
android:paddingRight="16dp"
|
android:paddingRight="16dp"
|
||||||
android:paddingBottom="4dp"
|
android:paddingBottom="4dp"
|
||||||
android:text="@string/poll_vote"
|
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
|
<TextView
|
||||||
android:id="@+id/status_poll_description"
|
android:id="@+id/status_poll_description"
|
||||||
android:layout_width="wrap_content"
|
android:layout_width="wrap_content"
|
||||||
android:layout_height="wrap_content"
|
android:layout_height="wrap_content"
|
||||||
android:layout_marginTop="6dp"
|
android:layout_marginTop="8dp"
|
||||||
android:textSize="?attr/status_text_medium"
|
|
||||||
android:textIsSelectable="true"
|
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" />
|
tools:text="7 votes • 7 hours remaining" />
|
||||||
</merge>
|
</merge>
|
||||||
|
|
|
@ -229,7 +229,7 @@
|
||||||
<item>31536000</item>
|
<item>31536000</item>
|
||||||
</integer-array>
|
</integer-array>
|
||||||
|
|
||||||
<string name="poll_percent_format"><!-- 15% --> <b>%1$d%%</b></string>
|
<string name="poll_percent_format"><!-- 15% -->%1$d%%</string>
|
||||||
|
|
||||||
<string-array name="mute_duration_names">
|
<string-array name="mute_duration_names">
|
||||||
<item>@string/duration_indefinite</item>
|
<item>@string/duration_indefinite</item>
|
||||||
|
|
|
@ -513,6 +513,7 @@
|
||||||
<string name="poll_info_time_absolute">ends at %s</string>
|
<string name="poll_info_time_absolute">ends at %s</string>
|
||||||
<string name="poll_info_closed">closed</string>
|
<string name="poll_info_closed">closed</string>
|
||||||
<string name="poll_vote">Vote</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_voted">A poll you have voted in has ended</string>
|
||||||
<string name="poll_ended_created">A poll you created has ended</string>
|
<string name="poll_ended_created">A poll you created has ended</string>
|
||||||
<!--These are for timestamps on polls -->
|
<!--These are for timestamps on polls -->
|
||||||
|
|
Loading…
Reference in New Issue