From c50f10a9890031bd12b869990c3b8f3ded65c2c1 Mon Sep 17 00:00:00 2001 From: Nik Clayton Date: Sun, 15 Oct 2023 22:26:34 +0200 Subject: [PATCH] refactor: Extract Poll display code to `PollView` (#177) --- app/lint-baseline.xml | 61 ++--- .../pachli/adapter/StatusBaseViewHolder.java | 194 +++------------- app/src/main/java/app/pachli/view/PollView.kt | 218 ++++++++++++++++++ app/src/main/res/layout/item_conversation.xml | 37 +-- app/src/main/res/layout/item_status.xml | 38 +-- .../main/res/layout/item_status_detailed.xml | 40 +--- app/src/main/res/layout/status_poll.xml | 52 +++++ 7 files changed, 330 insertions(+), 310 deletions(-) create mode 100644 app/src/main/java/app/pachli/view/PollView.kt create mode 100644 app/src/main/res/layout/status_poll.xml diff --git a/app/lint-baseline.xml b/app/lint-baseline.xml index 63c50c703..4870fdb61 100644 --- a/app/lint-baseline.xml +++ b/app/lint-baseline.xml @@ -1,5 +1,5 @@ - + @@ -3798,7 +3798,7 @@ errorLine2=" ~~~~~~~"> @@ -3809,7 +3809,7 @@ errorLine2=" ~~~~~~~"> @@ -3820,7 +3820,7 @@ errorLine2=" ~~~~~~~"> @@ -3886,7 +3886,7 @@ errorLine2=" ~~~~~~~"> @@ -3897,7 +3897,7 @@ errorLine2=" ~~~~~~~~~~~~~~~~~~~~~"> @@ -4381,17 +4381,6 @@ column="6"/> - - - - @@ -4828,7 +4817,7 @@ errorLine2=" ~~~~~~~~"> @@ -4839,18 +4828,7 @@ errorLine2=" ~~~~~~~~"> - - - - @@ -4916,7 +4894,7 @@ errorLine2=" ~~~~~~~~"> @@ -4927,7 +4905,7 @@ errorLine2=" ~~~~~~~~"> @@ -4938,18 +4916,7 @@ errorLine2=" ~~~~~~~~"> - - - - @@ -5235,7 +5202,7 @@ errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~"> diff --git a/app/src/main/java/app/pachli/adapter/StatusBaseViewHolder.java b/app/src/main/java/app/pachli/adapter/StatusBaseViewHolder.java index 8fd30a0a9..d19226b02 100644 --- a/app/src/main/java/app/pachli/adapter/StatusBaseViewHolder.java +++ b/app/src/main/java/app/pachli/adapter/StatusBaseViewHolder.java @@ -1,7 +1,5 @@ package app.pachli.adapter; -import static app.pachli.viewdata.PollViewDataKt.buildDescription; - import android.content.Context; import android.graphics.drawable.BitmapDrawable; import android.graphics.drawable.ColorDrawable; @@ -26,8 +24,6 @@ import androidx.appcompat.widget.PopupMenu; import androidx.constraintlayout.widget.ConstraintLayout; import androidx.core.content.ContextCompat; import androidx.core.text.HtmlCompat; -import androidx.recyclerview.widget.DefaultItemAnimator; -import androidx.recyclerview.widget.LinearLayoutManager; import androidx.recyclerview.widget.RecyclerView; import com.bumptech.glide.Glide; @@ -69,9 +65,8 @@ import app.pachli.util.TimestampUtils; import app.pachli.util.TouchDelegateHelper; import app.pachli.view.MediaPreviewImageView; import app.pachli.view.MediaPreviewLayout; -import app.pachli.viewdata.PollOptionViewData; +import app.pachli.view.PollView; import app.pachli.viewdata.PollViewData; -import app.pachli.viewdata.PollViewDataKt; import app.pachli.viewdata.StatusViewData; import at.connyduck.sparkbutton.SparkButton; import at.connyduck.sparkbutton.helpers.Utils; @@ -108,18 +103,14 @@ public abstract class StatusBaseViewHolder extends RecyclerView.ViewHolder { public final TextView content; public final TextView contentWarningDescription; - private final RecyclerView pollOptions; - private final TextView pollDescription; - private final Button pollButton; - + @NonNull + private final PollView pollView; private final LinearLayout cardView; private final LinearLayout cardInfo; private final ShapeableImageView cardImage; private final TextView cardTitle; private final TextView cardDescription; private final TextView cardUrl; - @NonNull - private final PollAdapter pollAdapter; protected final LinearLayout filteredPlaceholder; protected final TextView filteredPlaceholderLabel; protected final Button filteredPlaceholderShowButton; @@ -166,9 +157,7 @@ public abstract class StatusBaseViewHolder extends RecyclerView.ViewHolder { contentWarningButton = itemView.findViewById(R.id.status_content_warning_button); avatarInset = itemView.findViewById(R.id.status_avatar_inset); - pollOptions = itemView.findViewById(R.id.status_poll_options); - pollDescription = itemView.findViewById(R.id.status_poll_description); - pollButton = itemView.findViewById(R.id.status_poll_button); + pollView = itemView.findViewById(R.id.status_poll); cardView = itemView.findViewById(R.id.status_card_view); cardInfo = itemView.findViewById(R.id.card_info); @@ -182,13 +171,6 @@ public abstract class StatusBaseViewHolder extends RecyclerView.ViewHolder { filteredPlaceholderShowButton = itemView.findViewById(R.id.status_filter_show_anyway); statusContainer = itemView.findViewById(R.id.status_container); - pollAdapter = new PollAdapter(); - pollOptions.setAdapter(pollAdapter); - pollOptions.setLayoutManager(new LinearLayoutManager(pollOptions.getContext())); - - DefaultItemAnimator itemAnimator = (DefaultItemAnimator) pollOptions.getItemAnimator(); - if (itemAnimator != null) itemAnimator.setSupportsChangeAnimations(false); - this.avatarRadius48dp = itemView.getContext().getResources().getDimensionPixelSize(R.dimen.avatar_radius_48dp); this.avatarRadius36dp = itemView.getContext().getResources().getDimensionPixelSize(R.dimen.avatar_radius_36dp); this.avatarRadius24dp = itemView.getContext().getResources().getDimensionPixelSize(R.dimen.avatar_radius_24dp); @@ -289,12 +271,29 @@ public abstract class StatusBaseViewHolder extends RecyclerView.ViewHolder { updateMediaLabel(i, sensitive, true); } if (poll != null) { - setupPoll(PollViewData.Companion.from(poll), emojis, statusDisplayOptions, listener); + PollView.OnClickListener pollListener = (List choices) -> { + int position = getBindingAdapterPosition(); + if (position != RecyclerView.NO_POSITION) { + if (choices == null) { + listener.onViewThread(position); + } else { + listener.onVoteInPoll(position, choices); + } + } + }; + pollView.bind( + PollViewData.Companion.from(poll), + emojis, + statusDisplayOptions, + numberFormat, + absoluteTimeFormatter, + pollListener + ); } else { - hidePoll(); + pollView.hide(); } } else { - hidePoll(); + pollView.hide(); LinkHelper.setClickableMentions(this.content, mentions, listener); } if (TextUtils.isEmpty(this.content.getText())) { @@ -304,12 +303,6 @@ public abstract class StatusBaseViewHolder extends RecyclerView.ViewHolder { } } - private void hidePoll() { - pollButton.setVisibility(View.GONE); - pollDescription.setVisibility(View.GONE); - pollOptions.setVisibility(View.GONE); - } - private void setAvatar(String url, @Nullable String rebloggedUrl, boolean isBot, @@ -875,6 +868,17 @@ public abstract class StatusBaseViewHolder extends RecyclerView.ViewHolder { Context context = itemView.getContext(); Status actionable = status.getActionable(); + Poll poll = actionable.getPoll(); + CharSequence pollDescription = ""; + if (poll != null) { + pollDescription = pollView.getPollDescription( + PollViewData.Companion.from(poll), + statusDisplayOptions, + numberFormat, + absoluteTimeFormatter + ); + } + String description = context.getString(R.string.description_status, actionable.getAccount().getDisplayName(), getContentWarningDescription(context, status), @@ -890,7 +894,7 @@ public abstract class StatusBaseViewHolder extends RecyclerView.ViewHolder { getVisibilityDescription(context, actionable.getVisibility()), getFavsText(context, actionable.getFavouritesCount()), getReblogsText(context, actionable.getReblogsCount()), - getPollDescription(status, context, statusDisplayOptions) + pollDescription ); itemView.setContentDescription(description); } @@ -959,34 +963,6 @@ public abstract class StatusBaseViewHolder extends RecyclerView.ViewHolder { return context.getString(resource); } - @NonNull - private CharSequence getPollDescription(@NonNull StatusViewData status, - @NonNull Context context, - @NonNull StatusDisplayOptions statusDisplayOptions) { - Poll poll = status.getActionable().getPoll(); - if (poll == null) { - return ""; - } - - PollViewData pollViewData = PollViewData.Companion.from(poll); - Object[] args = new CharSequence[5]; - List options = pollViewData.getOptions(); - int totalVotes = pollViewData.getVotesCount(); - Integer totalVoters = pollViewData.getVotersCount(); - - for (int i = 0; i < args.length; i++) { - if (i < options.size()) { - int percent = PollViewDataKt.calculatePercent(options.get(i).getVotesCount(), totalVoters, totalVotes); - args[i] = buildDescription(options.get(i).getTitle(), percent, options.get(i).getVoted(), context); - } else { - args[i] = ""; - } - } - args[4] = getPollInfoText(System.currentTimeMillis(), pollViewData, statusDisplayOptions, - context); - return context.getString(R.string.description_poll, args); - } - @NonNull protected CharSequence getFavsText(@NonNull Context context, int count) { if (count > 0) { @@ -1007,100 +983,6 @@ public abstract class StatusBaseViewHolder extends RecyclerView.ViewHolder { } } - private void setupPoll(@NonNull PollViewData poll, @NonNull List emojis, - @NonNull StatusDisplayOptions statusDisplayOptions, - @NonNull StatusActionListener listener) { - long timestamp = System.currentTimeMillis(); - - boolean expired = poll.getExpired() || (poll.getExpiresAt() != null && timestamp > poll.getExpiresAt().getTime()); - - Context context = pollDescription.getContext(); - - pollOptions.setVisibility(View.VISIBLE); - - if (expired || poll.getVoted()) { - // no voting possible - View.OnClickListener viewThreadListener = v -> { - int position = getBindingAdapterPosition(); - if (position != RecyclerView.NO_POSITION) { - listener.onViewThread(position); - } - }; - pollAdapter.setup( - poll.getOptions(), - poll.getVotesCount(), - poll.getVotersCount(), - emojis, - PollAdapter.RESULT, - viewThreadListener, - statusDisplayOptions.animateEmojis() - ); - - pollButton.setVisibility(View.GONE); - } else { - // voting possible - View.OnClickListener optionClickListener = v -> { - pollButton.setEnabled(!pollAdapter.getSelected().isEmpty()); - }; - - pollAdapter.setup( - poll.getOptions(), - poll.getVotesCount(), - poll.getVotersCount(), - emojis, - poll.getMultiple() ? PollAdapter.MULTIPLE : PollAdapter.SINGLE, - null, - statusDisplayOptions.animateEmojis(), - true, - optionClickListener - ); - - pollButton.setVisibility(View.VISIBLE); - pollButton.setEnabled(false); - - pollButton.setOnClickListener(v -> { - int position = getBindingAdapterPosition(); - if (position != RecyclerView.NO_POSITION) { - List pollResult = pollAdapter.getSelected(); - if (!pollResult.isEmpty()) { - listener.onVoteInPoll(position, pollResult); - } - } - }); - } - - pollDescription.setVisibility(View.VISIBLE); - pollDescription.setText(getPollInfoText(timestamp, poll, statusDisplayOptions, context)); - } - - @NonNull - private CharSequence getPollInfoText(long timestamp, @NonNull PollViewData poll, - @NonNull StatusDisplayOptions statusDisplayOptions, - @NonNull Context context) { - String votesText; - if (poll.getVotersCount() == null) { - String voters = numberFormat.format(poll.getVotesCount()); - votesText = context.getResources().getQuantityString(R.plurals.poll_info_votes, poll.getVotesCount(), voters); - } else { - String voters = numberFormat.format(poll.getVotersCount()); - votesText = context.getResources().getQuantityString(R.plurals.poll_info_people, poll.getVotersCount(), voters); - } - CharSequence pollDurationInfo; - if (poll.getExpired()) { - pollDurationInfo = context.getString(R.string.poll_info_closed); - } else if (poll.getExpiresAt() == null) { - return votesText; - } else { - if (statusDisplayOptions.useAbsoluteTime()) { - pollDurationInfo = context.getString(R.string.poll_info_time_absolute, absoluteTimeFormatter.format(poll.getExpiresAt(), false)); - } else { - pollDurationInfo = TimestampUtils.formatPollDuration(pollDescription.getContext(), poll.getExpiresAt().getTime(), timestamp); - } - } - - return pollDescription.getContext().getString(R.string.poll_info_format, votesText, pollDurationInfo); - } - protected void setupCard( @NonNull final StatusViewData status, boolean expanded, @@ -1245,9 +1127,7 @@ public abstract class StatusBaseViewHolder extends RecyclerView.ViewHolder { content.setVisibility(visibility); cardView.setVisibility(visibility); mediaContainer.setVisibility(visibility); - pollOptions.setVisibility(visibility); - pollButton.setVisibility(visibility); - pollDescription.setVisibility(visibility); + pollView.setVisibility(visibility); replyButton.setVisibility(visibility); reblogButton.setVisibility(visibility); favouriteButton.setVisibility(visibility); diff --git a/app/src/main/java/app/pachli/view/PollView.kt b/app/src/main/java/app/pachli/view/PollView.kt new file mode 100644 index 000000000..1103e0d5d --- /dev/null +++ b/app/src/main/java/app/pachli/view/PollView.kt @@ -0,0 +1,218 @@ +/* + * Copyright 2023 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 . + */ + +package app.pachli.view + +import android.annotation.SuppressLint +import android.content.Context +import android.util.AttributeSet +import android.view.LayoutInflater +import android.widget.LinearLayout +import androidx.recyclerview.widget.DefaultItemAnimator +import androidx.recyclerview.widget.LinearLayoutManager +import app.pachli.R +import app.pachli.adapter.PollAdapter +import app.pachli.databinding.StatusPollBinding +import app.pachli.entity.Emoji +import app.pachli.util.AbsoluteTimeFormatter +import app.pachli.util.StatusDisplayOptions +import app.pachli.util.formatPollDuration +import app.pachli.util.hide +import app.pachli.util.show +import app.pachli.viewdata.PollViewData +import app.pachli.viewdata.buildDescription +import app.pachli.viewdata.calculatePercent +import java.text.NumberFormat + +/** + * Compound view that displays [PollViewData]. + * + * If the poll is still active / the user hasn't voted then poll options are shown as radio + * buttons / checkboxes (depending on whether or not it is multiple choice) and the user can + * vote. + * + * Otherwise the results of the poll are shown. + * + * Classes hosting this should provide a [PollView.OnClickListener] to be notified when the + * user clicks on the poll (either to vote, or to navigate). + */ +class PollView @JvmOverloads constructor( + context: Context, + 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?): Unit + } + + val binding: StatusPollBinding + + init { + val inflater = context.getSystemService(LayoutInflater::class.java) + binding = StatusPollBinding.inflate(inflater, this) + orientation = VERTICAL + } + + fun bind( + pollViewData: PollViewData, + emojis: List, + statusDisplayOptions: StatusDisplayOptions, + numberFormat: NumberFormat, + absoluteTimeFormatter: AbsoluteTimeFormatter, + listener: OnClickListener, + ) { + val adapter = PollAdapter() + binding.statusPollOptions.adapter = adapter + binding.statusPollOptions.layoutManager = LinearLayoutManager(context) + (binding.statusPollOptions.itemAnimator as? DefaultItemAnimator)?.supportsChangeAnimations = false + + val now = System.currentTimeMillis() + + binding.statusPollOptions.show() + + binding.statusPollDescription.text = getPollInfoText( + pollViewData, + now, + statusDisplayOptions, + numberFormat, + absoluteTimeFormatter, + ) + binding.statusPollDescription.show() + + val expired = pollViewData.expired || ((pollViewData.expiresAt != null) && (now > pollViewData.expiresAt.time)) + + // Poll expired or already voted, can't vote now + if (expired || pollViewData.voted) { + adapter.setup( + pollViewData.options, + pollViewData.votesCount, + pollViewData.votersCount, + emojis, + PollAdapter.RESULT, + { listener.onClick(null) }, + statusDisplayOptions.animateEmojis, + ) + binding.statusPollButton.hide() + return + } + + // Active poll, can vote + adapter.setup( + pollViewData.options, + pollViewData.votesCount, + pollViewData.votersCount, + emojis, + if (pollViewData.multiple) PollAdapter.MULTIPLE else PollAdapter.SINGLE, + null, + statusDisplayOptions.animateEmojis, + true, + ) { + binding.statusPollButton.isEnabled = adapter.getSelected().isNotEmpty() + } + + binding.statusPollButton.show() + binding.statusPollButton.isEnabled = false + binding.statusPollButton.setOnClickListener { + val selected = adapter.getSelected() + if (selected.isNotEmpty()) listener.onClick(selected) + } + } + + fun hide() { + binding.statusPollOptions.hide() + binding.statusPollButton.hide() + binding.statusPollDescription.hide() + } + + private fun getPollInfoText( + pollViewData: PollViewData, + timestamp: Long, + statusDisplayOptions: StatusDisplayOptions, + numberFormat: NumberFormat, + absoluteTimeFormatter: AbsoluteTimeFormatter, + ): CharSequence { + val votesText: String = if (pollViewData.votersCount == null) { + val voters = numberFormat.format(pollViewData.votesCount.toLong()) + context.resources.getQuantityString( + R.plurals.poll_info_votes, + pollViewData.votesCount, + voters, + ) + } else { + val voters = numberFormat.format(pollViewData.votersCount) + context.resources.getQuantityString( + R.plurals.poll_info_people, + pollViewData.votersCount, + voters, + ) + } + val pollDurationInfo: CharSequence = if (pollViewData.expired) { + context.getString(R.string.poll_info_closed) + } else if (pollViewData.expiresAt == null) { + return votesText + } else { + if (statusDisplayOptions.useAbsoluteTime) { + context.getString( + R.string.poll_info_time_absolute, + absoluteTimeFormatter.format(pollViewData.expiresAt, false), + ) + } else { + formatPollDuration(context, pollViewData.expiresAt.time, timestamp) + } + } + return context.getString( + R.string.poll_info_format, + votesText, + pollDurationInfo, + ) + } + + @SuppressLint("StringFormatMatches") // Lint doesn't understand the spread (*) operator + fun getPollDescription( + pollViewData: PollViewData, + statusDisplayOptions: StatusDisplayOptions, + numberFormat: NumberFormat, + absoluteTimeFormatter: AbsoluteTimeFormatter, + ): CharSequence { + val args: Array = arrayOfNulls(5) + val options = pollViewData.options + val totalVotes = pollViewData.votesCount + val totalVoters = pollViewData.votersCount + for (i in args.indices) { + if (i < options.size) { + val percent = calculatePercent(options[i].votesCount, totalVoters, totalVotes) + args[i] = buildDescription(options[i].title, percent, options[i].voted, context) + } else { + args[i] = "" + } + } + args[4] = getPollInfoText( + pollViewData, + System.currentTimeMillis(), + statusDisplayOptions, + numberFormat, + absoluteTimeFormatter, + ) + return context.getString(R.string.description_poll, *args) + } +} diff --git a/app/src/main/res/layout/item_conversation.xml b/app/src/main/res/layout/item_conversation.xml index 1da91c114..9a52265de 100644 --- a/app/src/main/res/layout/item_conversation.xml +++ b/app/src/main/res/layout/item_conversation.xml @@ -201,46 +201,15 @@ - -