diff --git a/app/lint-baseline.xml b/app/lint-baseline.xml
index dc2ef2667..f96b9e7f5 100644
--- a/app/lint-baseline.xml
+++ b/app/lint-baseline.xml
@@ -751,7 +751,7 @@
errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~">
@@ -1543,7 +1543,7 @@
errorLine2=" ~~~~~~~~~~~~~~~~~~~~">
@@ -1554,7 +1554,7 @@
errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~">
@@ -1565,7 +1565,7 @@
errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~">
@@ -1576,7 +1576,7 @@
errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~">
@@ -1587,7 +1587,7 @@
errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~">
@@ -2720,6 +2720,17 @@
column="9"/>
+
+
+
+
) -> 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>() {
/** 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)
}
}
}
diff --git a/app/src/main/java/app/pachli/view/PollView.kt b/app/src/main/java/app/pachli/view/PollView.kt
index 86ca052c7..eaaee2e67 100644
--- a/app/src/main/java/app/pachli/view/PollView.kt
+++ b/app/src/main/java/app/pachli/view/PollView.kt
@@ -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?) -> 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?)
- }
-
+) : 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%"
+ }
+}
diff --git a/app/src/main/java/app/pachli/viewdata/PollViewData.kt b/app/src/main/java/app/pachli/viewdata/PollViewData.kt
index 9e04f8ce5..2b008c2bf 100644
--- a/app/src/main/java/app/pachli/viewdata/PollViewData.kt
+++ b/app/src/main/java/app/pachli/viewdata/PollViewData.kt
@@ -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)
}
diff --git a/app/src/main/res/drawable/poll_option_background_inset.xml b/app/src/main/res/drawable/poll_option_background_inset.xml
new file mode 100644
index 000000000..89c067ed9
--- /dev/null
+++ b/app/src/main/res/drawable/poll_option_background_inset.xml
@@ -0,0 +1,22 @@
+
+
+
diff --git a/app/src/main/res/layout/item_poll.xml b/app/src/main/res/layout/item_poll.xml
index af1d0e227..08cbc2c2b 100644
--- a/app/src/main/res/layout/item_poll.xml
+++ b/app/src/main/res/layout/item_poll.xml
@@ -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%" />
diff --git a/app/src/main/res/layout/item_status.xml b/app/src/main/res/layout/item_status.xml
index e7a774a1b..ac9a6c2b4 100644
--- a/app/src/main/res/layout/item_status.xml
+++ b/app/src/main/res/layout/item_status.xml
@@ -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" />
.
-->
-
+
+ android:nestedScrollingEnabled="false"
+ tools:listitem="@layout/item_poll"
+ tools:itemCount="4" />
+ 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" />
+
+
diff --git a/app/src/main/res/values/donottranslate.xml b/app/src/main/res/values/donottranslate.xml
index d4ae2ce79..550656a6f 100644
--- a/app/src/main/res/values/donottranslate.xml
+++ b/app/src/main/res/values/donottranslate.xml
@@ -229,7 +229,7 @@
- 31536000
- <b>%1$d%%</b>
+ %1$d%%
- @string/duration_indefinite
diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml
index 1e1e6cf11..15207b7ad 100644
--- a/app/src/main/res/values/strings.xml
+++ b/app/src/main/res/values/strings.xml
@@ -513,6 +513,7 @@
ends at %s
closed
Vote
+ Show votes
A poll you have voted in has ended
A poll you created has ended