Poll voting

This commit is contained in:
Grishka 2022-02-16 03:58:13 +03:00
parent f7ddac6ae6
commit 1a238d79e0
18 changed files with 345 additions and 31 deletions

View File

@ -0,0 +1,21 @@
package org.joinmastodon.android.api.requests.polls;
import org.joinmastodon.android.api.MastodonAPIRequest;
import org.joinmastodon.android.model.Poll;
import java.util.List;
public class SubmitPollVote extends MastodonAPIRequest<Poll>{
public SubmitPollVote(String pollID, List<Integer> choices){
super(HttpMethod.POST, "/polls/"+pollID+"/votes", Poll.class);
setRequestBody(new Body(choices));
}
private static class Body{
public List<Integer> choices;
public Body(List<Integer> choices){
this.choices=choices;
}
}
}

View File

@ -16,24 +16,31 @@ import android.widget.ImageView;
import android.widget.Toolbar;
import org.joinmastodon.android.R;
import org.joinmastodon.android.api.requests.polls.SubmitPollVote;
import org.joinmastodon.android.model.Account;
import org.joinmastodon.android.model.DisplayItemsParent;
import org.joinmastodon.android.model.Poll;
import org.joinmastodon.android.model.Status;
import org.joinmastodon.android.ui.displayitems.FooterStatusDisplayItem;
import org.joinmastodon.android.ui.displayitems.ImageStatusDisplayItem;
import org.joinmastodon.android.ui.displayitems.PhotoStatusDisplayItem;
import org.joinmastodon.android.ui.displayitems.PollFooterStatusDisplayItem;
import org.joinmastodon.android.ui.displayitems.PollOptionStatusDisplayItem;
import org.joinmastodon.android.ui.displayitems.StatusDisplayItem;
import org.joinmastodon.android.ui.photoviewer.PhotoViewer;
import org.joinmastodon.android.ui.photoviewer.PhotoViewerHost;
import java.util.ArrayList;
import java.util.Collections;
import java.util.HashMap;
import java.util.List;
import java.util.stream.Collectors;
import androidx.annotation.NonNull;
import androidx.recyclerview.widget.GridLayoutManager;
import androidx.recyclerview.widget.RecyclerView;
import me.grishka.appkit.api.Callback;
import me.grishka.appkit.api.ErrorResponse;
import me.grishka.appkit.fragments.BaseRecyclerFragment;
import me.grishka.appkit.imageloader.ImageLoaderRecyclerAdapter;
import me.grishka.appkit.imageloader.ImageLoaderViewHolder;
@ -334,11 +341,86 @@ public abstract class BaseStatusListFragment<T extends DisplayItemsParent> exten
return 0;
}
public void onItemClick(String id){
public abstract void onItemClick(String id);
protected void updatePoll(String itemID, Poll poll){
int firstOptionIndex=-1, footerIndex=-1;
int i=0;
for(StatusDisplayItem item:displayItems){
if(item.parentID.equals(itemID)){
if(item instanceof PollOptionStatusDisplayItem && firstOptionIndex==-1){
firstOptionIndex=i;
}else if(item instanceof PollFooterStatusDisplayItem){
footerIndex=i;
break;
}
}
i++;
}
if(firstOptionIndex==-1 || footerIndex==-1)
throw new IllegalStateException("Can't find all poll items in displayItems");
List<StatusDisplayItem> pollItems=displayItems.subList(firstOptionIndex, footerIndex+1);
int prevSize=pollItems.size();
pollItems.clear();
StatusDisplayItem.buildPollItems(itemID, this, poll, pollItems);
if(prevSize!=pollItems.size()){
adapter.notifyItemRangeRemoved(firstOptionIndex, prevSize);
adapter.notifyItemRangeInserted(firstOptionIndex, pollItems.size());
}else{
adapter.notifyItemRangeChanged(firstOptionIndex, pollItems.size());
}
}
public void onPollOptionClick(PollOptionStatusDisplayItem.Holder holder){
Poll poll=holder.getItem().poll;
Poll.Option option=holder.getItem().option;
if(poll.multiple){
if(poll.selectedOptions==null)
poll.selectedOptions=new ArrayList<>();
if(poll.selectedOptions.contains(option)){
poll.selectedOptions.remove(option);
holder.itemView.setSelected(false);
}else{
poll.selectedOptions.add(option);
holder.itemView.setSelected(true);
}
for(int i=0;i<list.getChildCount();i++){
RecyclerView.ViewHolder vh=list.getChildViewHolder(list.getChildAt(i));
if(vh instanceof PollFooterStatusDisplayItem.Holder){
PollFooterStatusDisplayItem.Holder footer=(PollFooterStatusDisplayItem.Holder) vh;
if(footer.getItemID().equals(holder.getItemID())){
footer.rebind();
break;
}
}
}
}else{
submitPollVote(holder.getItemID(), poll.id, Collections.singletonList(poll.options.indexOf(option)));
}
}
public void onPollVoteButtonClick(PollFooterStatusDisplayItem.Holder holder){
Poll poll=holder.getItem().poll;
submitPollVote(holder.getItemID(), poll.id, poll.selectedOptions.stream().map(opt->poll.options.indexOf(opt)).collect(Collectors.toList()));
}
protected void submitPollVote(String parentID, String pollID, List<Integer> choices){
if(refreshing)
return;
new SubmitPollVote(pollID, choices)
.setCallback(new Callback<>(){
@Override
public void onSuccess(Poll result){
updatePoll(parentID, result);
}
@Override
public void onError(ErrorResponse error){
error.showToast(getActivity());
}
})
.wrapProgress(getActivity(), R.string.loading, false)
.exec(accountID);
}
protected class DisplayItemsAdapter extends UsableRecyclerView.Adapter<BindableViewHolder<StatusDisplayItem>> implements ImageLoaderRecyclerAdapter{

View File

@ -5,6 +5,7 @@ import android.app.Activity;
import org.joinmastodon.android.R;
import org.joinmastodon.android.api.requests.notifications.GetNotifications;
import org.joinmastodon.android.model.Notification;
import org.joinmastodon.android.model.Poll;
import org.joinmastodon.android.ui.displayitems.ReblogOrReplyLineStatusDisplayItem;
import org.joinmastodon.android.ui.displayitems.StatusDisplayItem;
@ -67,4 +68,26 @@ public class NotificationsFragment extends BaseStatusListFragment<Notification>{
if(!getArguments().getBoolean("noAutoLoad") && !loaded && !dataLoading)
loadData();
}
@Override
public void onItemClick(String id){
}
@Override
protected void updatePoll(String itemID, Poll poll){
Notification notification=getNotificationByID(itemID);
if(notification==null || notification.status==null)
return;
notification.status.poll=poll;
super.updatePoll(itemID, poll);
}
private Notification getNotificationByID(String id){
for(Notification n:data){
if(n.id.equals(id))
return n;
}
return null;
}
}

View File

@ -6,8 +6,11 @@ import com.squareup.otto.Subscribe;
import org.joinmastodon.android.E;
import org.joinmastodon.android.events.StatusCountersUpdatedEvent;
import org.joinmastodon.android.model.Poll;
import org.joinmastodon.android.model.Status;
import org.joinmastodon.android.ui.displayitems.FooterStatusDisplayItem;
import org.joinmastodon.android.ui.displayitems.PollFooterStatusDisplayItem;
import org.joinmastodon.android.ui.displayitems.PollOptionStatusDisplayItem;
import org.joinmastodon.android.ui.displayitems.StatusDisplayItem;
import org.parceler.Parcels;
@ -41,13 +44,7 @@ public abstract class StatusListFragment extends BaseStatusListFragment<Status>{
@Override
public void onItemClick(String id){
Status status=null;
for(Status s:data){
if(s.id.equals(id)){
status=s.getContentStatus();
break;
}
}
Status status=getContentStatusByID(id);
if(status==null)
return;
Bundle args=new Bundle();
@ -58,6 +55,15 @@ public abstract class StatusListFragment extends BaseStatusListFragment<Status>{
Nav.go(getActivity(), ThreadFragment.class, args);
}
@Override
protected void updatePoll(String itemID, Poll poll){
Status status=getContentStatusByID(itemID);
if(status==null)
return;
status.poll=poll;
super.updatePoll(itemID, poll);
}
@Subscribe
public void onStatusCountersUpdated(StatusCountersUpdatedEvent ev){
for(Status s:data){
@ -80,4 +86,13 @@ public abstract class StatusListFragment extends BaseStatusListFragment<Status>{
}
}
}
protected Status getContentStatusByID(String id){
for(Status s:data){
if(s.id.equals(id)){
return s.getContentStatus();
}
}
return null;
}
}

View File

@ -5,6 +5,7 @@ import org.joinmastodon.android.api.ObjectValidationException;
import org.parceler.Parcel;
import java.time.Instant;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.List;
@ -21,6 +22,8 @@ public class Poll extends BaseModel{
public List<Option> options;
public List<Emoji> emojis;
public transient ArrayList<Option> selectedOptions;
@Override
public void postprocess() throws ObjectValidationException{
super.postprocess();

View File

@ -2,7 +2,9 @@ package org.joinmastodon.android.ui.displayitems;
import android.app.Activity;
import android.content.Context;
import android.view.View;
import android.view.ViewGroup;
import android.widget.Button;
import android.widget.TextView;
import org.joinmastodon.android.R;
@ -11,7 +13,7 @@ import org.joinmastodon.android.model.Poll;
import org.joinmastodon.android.ui.utils.UiUtils;
public class PollFooterStatusDisplayItem extends StatusDisplayItem{
private Poll poll;
public final Poll poll;
public PollFooterStatusDisplayItem(String parentID, BaseStatusListFragment parentFragment, Poll poll){
super(parentID, parentFragment);
@ -25,10 +27,13 @@ public class PollFooterStatusDisplayItem extends StatusDisplayItem{
public static class Holder extends StatusDisplayItem.Holder<PollFooterStatusDisplayItem>{
private TextView text;
private Button button;
public Holder(Activity activity, ViewGroup parent){
super(activity, R.layout.display_item_poll_footer, parent);
text=(TextView) itemView;
text=findViewById(R.id.text);
button=findViewById(R.id.vote_btn);
button.setOnClickListener(v->item.parentFragment.onPollVoteButtonClick(this));
}
@Override
@ -40,6 +45,8 @@ public class PollFooterStatusDisplayItem extends StatusDisplayItem{
text+=" · "+item.parentFragment.getString(R.string.poll_closed);
}
this.text.setText(text);
button.setVisibility(item.poll.expired || item.poll.voted || !item.poll.multiple ? View.GONE : View.VISIBLE);
button.setEnabled(item.poll.selectedOptions!=null && !item.poll.selectedOptions.isEmpty());
}
}
}

View File

@ -3,6 +3,7 @@ package org.joinmastodon.android.ui.displayitems;
import android.app.Activity;
import android.graphics.drawable.Animatable;
import android.graphics.drawable.Drawable;
import android.util.StateSet;
import android.view.View;
import android.view.ViewGroup;
import android.widget.TextView;
@ -12,19 +13,34 @@ import org.joinmastodon.android.fragments.BaseStatusListFragment;
import org.joinmastodon.android.model.Poll;
import org.joinmastodon.android.ui.text.HtmlParser;
import java.util.Locale;
import me.grishka.appkit.imageloader.ImageLoaderViewHolder;
import me.grishka.appkit.imageloader.requests.ImageLoaderRequest;
public class PollOptionStatusDisplayItem extends StatusDisplayItem{
private CharSequence text;
private Poll.Option option;
public final Poll.Option option;
private CustomEmojiHelper emojiHelper=new CustomEmojiHelper();
private boolean showResults;
private float votesFraction; // 0..1
private boolean isMostVoted;
public final Poll poll;
public PollOptionStatusDisplayItem(String parentID, Poll poll, Poll.Option option, BaseStatusListFragment parentFragment){
super(parentID, parentFragment);
this.option=option;
this.poll=poll;
text=HtmlParser.parseCustomEmoji(option.title, poll.emojis);
emojiHelper.setText(text);
showResults=poll.expired || poll.voted;
if(showResults && option.votesCount!=null && poll.votersCount>0){
votesFraction=(float)option.votesCount/(float)poll.votersCount;
int mostVotedCount=0;
for(Poll.Option opt:poll.options)
mostVotedCount=Math.max(mostVotedCount, opt.votesCount);
isMostVoted=option.votesCount==mostVotedCount;
}
}
@Override
@ -43,19 +59,35 @@ public class PollOptionStatusDisplayItem extends StatusDisplayItem{
}
public static class Holder extends StatusDisplayItem.Holder<PollOptionStatusDisplayItem> implements ImageLoaderViewHolder{
private final TextView text;
private final View button;
private final TextView text, percent;
private final View icon, button;
private final Drawable progressBg;
public Holder(Activity activity, ViewGroup parent){
super(activity, R.layout.display_item_poll_option, parent);
text=findViewById(R.id.text);
percent=findViewById(R.id.percent);
icon=findViewById(R.id.icon);
button=findViewById(R.id.button);
progressBg=activity.getResources().getDrawable(R.drawable.bg_poll_option_voted, activity.getTheme()).mutate();
itemView.setOnClickListener(this::onButtonClick);
}
@Override
public void onBind(PollOptionStatusDisplayItem item){
text.setText(item.text);
icon.setVisibility(item.showResults ? View.GONE : View.VISIBLE);
percent.setVisibility(item.showResults ? View.VISIBLE : View.GONE);
itemView.setClickable(!item.showResults);
if(item.showResults){
progressBg.setLevel(Math.round(10000f*item.votesFraction));
button.setBackground(progressBg);
itemView.setSelected(item.isMostVoted);
percent.setText(String.format(Locale.getDefault(), "%d%%", Math.round(item.votesFraction*100f)));
}else{
itemView.setSelected(item.poll.selectedOptions!=null && item.poll.selectedOptions.contains(item.option));
button.setBackgroundResource(R.drawable.bg_poll_option_clickable);
}
}
@Override

View File

@ -16,6 +16,7 @@ import org.joinmastodon.android.model.Status;
import org.joinmastodon.android.ui.text.HtmlParser;
import java.util.ArrayList;
import java.util.List;
import java.util.Map;
import java.util.Objects;
@ -90,15 +91,19 @@ public abstract class StatusDisplayItem{
}
}
if(statusForContent.poll!=null){
for(Poll.Option opt:statusForContent.poll.options){
items.add(new PollOptionStatusDisplayItem(parentID, statusForContent.poll, opt, fragment));
}
items.add(new PollFooterStatusDisplayItem(parentID, fragment, statusForContent.poll));
buildPollItems(parentID, fragment, statusForContent.poll, items);
}
items.add(new FooterStatusDisplayItem(parentID, fragment, statusForContent, accountID));
return items;
}
public static void buildPollItems(String parentID, BaseStatusListFragment fragment, Poll poll, List<StatusDisplayItem> items){
for(Poll.Option opt:poll.options){
items.add(new PollOptionStatusDisplayItem(parentID, poll, opt, fragment));
}
items.add(new PollFooterStatusDisplayItem(parentID, fragment, poll));
}
public enum Type{
HEADER,
REBLOG_OR_REPLY_LINE,

View File

@ -0,0 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<selector xmlns:android="http://schemas.android.com/apk/res/android">
<item android:color="?colorPollMostVoted" android:state_selected="true"/>
<item android:color="?colorPollVoted"/>
</selector>

View File

@ -0,0 +1,17 @@
<?xml version="1.0" encoding="utf-8"?>
<layer-list xmlns:android="http://schemas.android.com/apk/res/android">
<item>
<shape>
<solid android:color="?android:colorBackground"/>
<corners android:radius="10dp"/>
</shape>
</item>
<item>
<clip android:clipOrientation="horizontal" android:gravity="start">
<shape>
<solid android:color="@color/poll_option_progress"/>
<corners android:radius="10dp"/>
</shape>
</clip>
</item>
</layer-list>

View File

@ -0,0 +1,3 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android" android:width="24dp" android:height="24dp" android:viewportWidth="24" android:viewportHeight="24">
<path android:pathData="M12 2c5.523 0 10 4.477 10 10s-4.477 10-10 10S2 17.523 2 12 6.477 2 12 2zm0 1.5c-4.694 0-8.5 3.806-8.5 8.5s3.806 8.5 8.5 8.5 8.5-3.806 8.5-8.5-3.806-8.5-8.5-8.5zm-1.25 9.94l4.47-4.47c0.293-0.293 0.767-0.293 1.06 0 0.267 0.266 0.29 0.683 0.073 0.976L16.28 10.03l-5 5c-0.266 0.267-0.683 0.29-0.976 0.073L10.22 15.03l-2.5-2.5c-0.293-0.293-0.293-0.767 0-1.06 0.266-0.267 0.683-0.29 0.976-0.073L8.78 11.47l1.97 1.97 4.47-4.47-4.47 4.47z" android:fillColor="@color/fluent_default_icon_tint"/>
</vector>

View File

@ -0,0 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<selector xmlns:android="http://schemas.android.com/apk/res/android">
<item android:drawable="@drawable/ic_fluent_checkmark_circle_24_regular" android:state_selected="true"/>
<item android:drawable="@drawable/ic_fluent_circle_24_regular"/>
</selector>

View File

@ -1,9 +1,27 @@
<?xml version="1.0" encoding="utf-8"?>
<TextView xmlns:android="http://schemas.android.com/apk/res/android"
<LinearLayout
xmlns:android="http://schemas.android.com/apk/res/android"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:paddingLeft="16dp"
android:paddingRight="16dp"
android:paddingBottom="8dp"
android:textAppearance="@style/m3_label_large"
android:textColor="?android:textColorPrimary" />
android:orientation="vertical">
<TextView
android:id="@+id/text"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:paddingLeft="16dp"
android:paddingRight="16dp"
android:paddingBottom="8dp"
android:textAppearance="@style/m3_label_large"
android:textColor="?android:textColorPrimary" />
<Button
android:id="@+id/vote_btn"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginStart="16dp"
android:layout_marginBottom="8dp"
android:enabled="false"
android:text="@string/action_vote"/>
</LinearLayout>

View File

@ -20,11 +20,24 @@
android:layoutDirection="locale">
<ImageView
android:id="@+id/icon"
android:layout_width="24dp"
android:layout_height="24dp"
android:layout_marginStart="12dp"
android:layout_gravity="center_vertical"
android:src="@drawable/ic_fluent_circle_24_regular"/>
android:duplicateParentState="true"
android:src="@drawable/ic_poll_option_button"/>
<TextView
android:id="@+id/percent"
android:layout_width="46dp"
android:layout_height="wrap_content"
android:layout_gravity="center_vertical"
android:layout_marginStart="16dp"
android:textAppearance="@style/m3_title_medium"
android:visibility="gone"
tools:visibility="visible"
tools:text="00.0%"/>
<TextView
android:id="@+id/text"

View File

@ -5,5 +5,7 @@
<attr name="colorBackgroundLight" format="color"/>
<attr name="colorBackgroundLightest" format="color"/>
<attr name="colorDarkIcon" format="color"/>
<attr name="colorPollMostVoted" format="color"/>
<attr name="colorPollVoted" format="color"/>
<attr name="secondaryButtonStyle" format="reference"/>
</resources>

View File

@ -1,10 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<color name="purple_200">#FFBB86FC</color>
<color name="purple_500">#FF6200EE</color>
<color name="purple_700">#FF3700B3</color>
<color name="teal_200">#FF03DAC5</color>
<color name="teal_700">#FF018786</color>
<color name="black">#FF000000</color>
<color name="white">#FFFFFFFF</color>
@ -24,13 +19,78 @@
<color name="gray_800t">#CC282C37</color>
<color name="gray_900">#101828</color>
<color name="primary_25">#FAFDFF</color>
<color name="primary_50">#EAF4FB</color>
<color name="primary_100">#D5E9F7</color>
<color name="primary_200">#BFDEF4</color>
<color name="primary_300">#AAD3F0</color>
<color name="primary_400">#95C8EC</color>
<color name="primary_500">#80BCE8</color>
<color name="primary_600">#55A6E1</color>
<color name="primary_700">#2B90D9</color>
<color name="primary_800">#2273AE</color>
<color name="primary_900">#16486D</color>
<color name="error_25">#FFFBFA</color>
<color name="error_50">#FEF3F2</color>
<color name="error_100">#FEE4E2</color>
<color name="error_200">#FECDCA</color>
<color name="error_300">#FDA29B</color>
<color name="error_400">#F97066</color>
<color name="error_500">#F04438</color>
<color name="error_600">#D92D20</color>
<color name="error_700">#B42318</color>
<color name="error_800">#912018</color>
<color name="error_900">#7A271A</color>
<color name="warning_25">#FFFCF5</color>
<color name="warning_50">#FFFAEB</color>
<color name="warning_100">#FEF0C7</color>
<color name="warning_200">#FEDF89</color>
<color name="warning_300">#FEC84B</color>
<color name="warning_400">#FDB022</color>
<color name="warning_500">#F79009</color>
<color name="warning_600">#DC6803</color>
<color name="warning_700">#B54708</color>
<color name="warning_800">#93370D</color>
<color name="warning_900">#7A2E0E</color>
<color name="success_25">#F6FEF9</color>
<color name="success_50">#ECFDF3</color>
<color name="success_100">#D1FADF</color>
<color name="success_200">#A6F4C5</color>
<color name="success_300">#6CE9A6</color>
<color name="success_400">#32D583</color>
<color name="success_500">#12B76A</color>
<color name="success_600">#039855</color>
<color name="success_700">#027A48</color>
<color name="success_800">#05603A</color>
<color name="success_900">#054F31</color>
<color name="slate_25">#FCFCFD</color>
<color name="slate_50">#F8FAFC</color>
<color name="slate_100">#EAEFF5</color>
<color name="slate_200">#C8D4E5</color>
<color name="slate_300">#9EB3D1</color>
<color name="slate_400">#7190BC</color>
<color name="slate_500">#4E73A6</color>
<color name="slate_600">#3E5B84</color>
<color name="slate_700">#364F72</color>
<color name="slate_800">#293C56</color>
<color name="slate_900">#101823</color>
<color name="purple_25">#FAFAFF</color>
<color name="purple_50">#F4F3FF</color>
<color name="purple_100">#EBE9FE</color>
<color name="purple_200">#D9D6FE</color>
<color name="purple_300">#BDB4FE</color>
<color name="purple_400">#9B8AFB</color>
<color name="purple_500">#7A5AF8</color>
<color name="purple_600">#6938EF</color>
<color name="purple_700">#5925DC</color>
<color name="purple_800">#4A1FB8</color>
<color name="purple_900">#3E1C96</color>
<color name="fab_icon">#282C37</color>
<color name="actionbar_bg">#FAFBFC</color>
<color name="navigation_bar_bg">#282C37</color>
<color name="highlight_over_dark">#80FFFFFF</color>
<color name="favorite_selected">#FF9F0A</color>
<color name="boost_selected">#79BD9A</color>
<color name="favorite_selected">@color/warning_500</color>
<color name="boost_selected">@color/primary_500</color>
</resources>

View File

@ -122,4 +122,5 @@
<string name="do_unblock">Unblock</string>
<string name="button_muted">Muted</string>
<string name="button_blocked">Blocked</string>
<string name="action_vote">Vote</string>
</resources>

View File

@ -28,6 +28,8 @@
<item name="android:windowLightNavigationBar" tools:ignore="NewApi">true</item>
<item name="android:actionBarTheme">@style/Theme.Mastodon.Toolbar</item>
<item name="android:alertDialogTheme">@style/Theme.Mastodon.Dialog.Alert</item>
<item name="colorPollMostVoted">@color/primary_500</item>
<item name="colorPollVoted">@color/gray_300</item>
</style>
<style name="Theme.Mastodon.Toolbar" parent="android:ThemeOverlay.Material.ActionBar">