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" />