Polls in compose

This commit is contained in:
Grishka 2022-02-13 00:29:15 +03:00
parent dc63d054dc
commit ce258f1b54
9 changed files with 374 additions and 5 deletions

View File

@ -14,7 +14,6 @@ import android.os.Bundle;
import android.text.Editable;
import android.text.TextUtils;
import android.text.TextWatcher;
import android.util.Log;
import android.view.Gravity;
import android.view.LayoutInflater;
import android.view.Menu;
@ -30,6 +29,7 @@ import android.widget.FrameLayout;
import android.widget.ImageButton;
import android.widget.ImageView;
import android.widget.LinearLayout;
import android.widget.PopupMenu;
import android.widget.ProgressBar;
import android.widget.TextView;
@ -50,11 +50,14 @@ import org.joinmastodon.android.model.Attachment;
import org.joinmastodon.android.model.Emoji;
import org.joinmastodon.android.model.EmojiCategory;
import org.joinmastodon.android.model.Mention;
import org.joinmastodon.android.model.Poll;
import org.joinmastodon.android.model.Status;
import org.joinmastodon.android.ui.CustomEmojiPopupKeyboard;
import org.joinmastodon.android.ui.M3AlertDialogBuilder;
import org.joinmastodon.android.ui.PopupKeyboard;
import org.joinmastodon.android.ui.text.HtmlParser;
import org.joinmastodon.android.ui.utils.SimpleTextWatcher;
import org.joinmastodon.android.ui.views.ReorderableLinearLayout;
import org.joinmastodon.android.ui.views.SizeListenerLinearLayout;
import org.parceler.Parcels;
@ -76,6 +79,7 @@ import me.grishka.appkit.utils.V;
public class ComposeFragment extends ToolbarFragment implements OnBackPressedListener{
private static final int MEDIA_RESULT=717;
private static final int MAX_POLL_OPTIONS=4;
private static final Pattern MENTION_PATTERN=Pattern.compile("(^|[^\\/\\w])@(([a-z0-9_]+)@[a-z0-9\\.\\-]+[a-z0-9]+)", Pattern.CASE_INSENSITIVE);
@ -112,6 +116,12 @@ public class ComposeFragment extends ToolbarFragment implements OnBackPressedLis
private ImageButton mediaBtn, pollBtn, emojiBtn, spoilerBtn, visibilityBtn;
private LinearLayout attachmentsView;
private TextView replyText;
private ReorderableLinearLayout pollOptionsView;
private View pollWrap;
private View addPollOptionBtn;
private TextView pollDurationView;
private ArrayList<DraftPollOption> pollOptions=new ArrayList<>();
private ArrayList<DraftMediaAttachment> queuedAttachments=new ArrayList<>(), failedAttachments=new ArrayList<>(), attachments=new ArrayList<>();
private DraftMediaAttachment uploadingAttachment;
@ -121,6 +131,7 @@ public class ComposeFragment extends ToolbarFragment implements OnBackPressedLis
private Status replyTo;
private String initialReplyMentions;
private String uuid;
private int pollDuration=24*3600;
@Override
public void onAttach(Activity activity){
@ -171,6 +182,7 @@ public class ComposeFragment extends ToolbarFragment implements OnBackPressedLis
replyText=view.findViewById(R.id.reply_text);
mediaBtn.setOnClickListener(v->openFilePicker());
pollBtn.setOnClickListener(v->togglePoll());
emojiBtn.setOnClickListener(v->emojiKeyboard.toggleKeyboardPopup(mainEditText));
emojiKeyboard.setOnIconChangedListener(new PopupKeyboard.OnIconChangeListener(){
@Override
@ -184,6 +196,18 @@ public class ComposeFragment extends ToolbarFragment implements OnBackPressedLis
emojiKeyboard.getView().setElevation(V.dp(2));
attachmentsView=view.findViewById(R.id.attachments);
pollOptionsView=view.findViewById(R.id.poll_options);
pollWrap=view.findViewById(R.id.poll_wrap);
addPollOptionBtn=view.findViewById(R.id.add_poll_option);
addPollOptionBtn.setOnClickListener(v->{
createDraftPollOption().edit.requestFocus();
updatePollOptionHints();
});
pollOptionsView.setDragListener(this::onSwapPollOptions);
pollDurationView=view.findViewById(R.id.poll_duration);
pollDurationView.setText(getString(R.string.compose_poll_duration, getResources().getQuantityString(R.plurals.x_days, 1, 1)));
pollDurationView.setOnClickListener(v->showPollDurationMenu());
return view;
}
@ -286,7 +310,13 @@ public class ComposeFragment extends ToolbarFragment implements OnBackPressedLis
private void updatePublishButtonState(){
uuid=null;
publishButton.setEnabled((trimmedCharCount>0 || !attachments.isEmpty()) && charCount<=charLimit && uploadingAttachment==null && failedAttachments.isEmpty() && queuedAttachments.isEmpty());
int nonEmptyPollOptionsCount=0;
for(DraftPollOption opt:pollOptions){
if(opt.edit.length()>0)
nonEmptyPollOptionsCount++;
}
publishButton.setEnabled((trimmedCharCount>0 || !attachments.isEmpty()) && charCount<=charLimit && uploadingAttachment==null && failedAttachments.isEmpty() && queuedAttachments.isEmpty()
&& (pollOptions.isEmpty() || nonEmptyPollOptionsCount>1));
}
private void onCustomEmojiClick(Emoji emoji){
@ -311,6 +341,12 @@ public class ComposeFragment extends ToolbarFragment implements OnBackPressedLis
if(replyTo!=null){
req.inReplyToId=replyTo.id;
}
if(!pollOptions.isEmpty()){
req.poll=new CreateStatus.Request.Poll();
req.poll.expiresIn=pollDuration;
for(DraftPollOption opt:pollOptions)
req.poll.options.add(opt.edit.getText().toString());
}
if(uuid==null)
uuid=UUID.randomUUID().toString();
ProgressDialog progress=new ProgressDialog(getActivity());
@ -324,8 +360,10 @@ public class ComposeFragment extends ToolbarFragment implements OnBackPressedLis
progress.dismiss();
Nav.finish(ComposeFragment.this);
E.post(new StatusCreatedEvent(result));
replyTo.repliesCount++;
E.post(new StatusCountersUpdatedEvent(replyTo));
if(replyTo!=null){
replyTo.repliesCount++;
E.post(new StatusCountersUpdatedEvent(replyTo));
}
}
@Override
@ -338,8 +376,11 @@ public class ComposeFragment extends ToolbarFragment implements OnBackPressedLis
}
private boolean hasDraft(){
boolean pollFieldsHaveContent=false;
for(DraftPollOption opt:pollOptions)
pollFieldsHaveContent|=opt.edit.length()>0;
return (mainEditText.length()>0 && !mainEditText.getText().toString().equals(initialReplyMentions)) || !attachments.isEmpty()
|| uploadingAttachment!=null || !queuedAttachments.isEmpty() || !failedAttachments.isEmpty();
|| uploadingAttachment!=null || !queuedAttachments.isEmpty() || !failedAttachments.isEmpty() || pollFieldsHaveContent;
}
@Override
@ -397,6 +438,7 @@ public class ComposeFragment extends ToolbarFragment implements OnBackPressedLis
}
private void addMediaAttachment(Uri uri){
pollBtn.setEnabled(false);
View thumb=getActivity().getLayoutInflater().inflate(R.layout.compose_media_thumb, attachmentsView, false);
ImageView img=thumb.findViewById(R.id.thumb);
ViewImageLoader.load(img, null, new UrlImageLoaderRequest(uri, V.dp(250), V.dp(250)));
@ -467,6 +509,8 @@ public class ComposeFragment extends ToolbarFragment implements OnBackPressedLis
att.uploadRequest.cancel();
if(!queuedAttachments.isEmpty())
uploadMediaAttachment(queuedAttachments.remove(0));
else
uploadingAttachment=null;
}else{
attachments.remove(att);
queuedAttachments.remove(att);
@ -474,6 +518,84 @@ public class ComposeFragment extends ToolbarFragment implements OnBackPressedLis
}
attachmentsView.removeView(att.view);
updatePublishButtonState();
pollBtn.setEnabled(attachments.isEmpty() && queuedAttachments.isEmpty() && failedAttachments.isEmpty() && uploadingAttachment==null);
}
private void togglePoll(){
if(pollOptions.isEmpty()){
pollBtn.setSelected(true);
mediaBtn.setEnabled(false);
pollWrap.setVisibility(View.VISIBLE);
for(int i=0;i<2;i++)
createDraftPollOption();
updatePollOptionHints();
}else{
pollBtn.setSelected(false);
mediaBtn.setEnabled(true);
pollWrap.setVisibility(View.GONE);
addPollOptionBtn.setVisibility(View.VISIBLE);
pollOptionsView.removeAllViews();
pollOptions.clear();
pollDuration=24*3600;
}
updatePublishButtonState();
}
private DraftPollOption createDraftPollOption(){
DraftPollOption option=new DraftPollOption();
option.view=LayoutInflater.from(getActivity()).inflate(R.layout.compose_poll_option, pollOptionsView, false);
option.edit=option.view.findViewById(R.id.edit);
option.dragger=option.view.findViewById(R.id.dragger_thingy);
option.dragger.setOnLongClickListener(v->{
pollOptionsView.startDragging(option.view);
return true;
});
option.edit.addTextChangedListener(new SimpleTextWatcher(e->updatePublishButtonState()));
pollOptionsView.addView(option.view);
pollOptions.add(option);
if(pollOptions.size()==MAX_POLL_OPTIONS)
addPollOptionBtn.setVisibility(View.GONE);
return option;
}
private void updatePollOptionHints(){
int i=0;
for(DraftPollOption option:pollOptions){
option.edit.setHint(getString(R.string.poll_option_hint, ++i));
}
}
private void onSwapPollOptions(int oldIndex, int newIndex){
pollOptions.add(newIndex, pollOptions.remove(oldIndex));
updatePollOptionHints();
}
private void showPollDurationMenu(){
PopupMenu menu=new PopupMenu(getActivity(), pollDurationView);
menu.getMenu().add(0, 1, 0, getResources().getQuantityString(R.plurals.x_minutes, 5, 5));
menu.getMenu().add(0, 2, 0, getResources().getQuantityString(R.plurals.x_minutes, 30, 30));
menu.getMenu().add(0, 3, 0, getResources().getQuantityString(R.plurals.x_hours, 1, 1));
menu.getMenu().add(0, 4, 0, getResources().getQuantityString(R.plurals.x_hours, 6, 6));
menu.getMenu().add(0, 5, 0, getResources().getQuantityString(R.plurals.x_days, 1, 1));
menu.getMenu().add(0, 6, 0, getResources().getQuantityString(R.plurals.x_days, 3, 3));
menu.getMenu().add(0, 7, 0, getResources().getQuantityString(R.plurals.x_days, 7, 7));
menu.setOnMenuItemClickListener(item->{
pollDuration=switch(item.getItemId()){
case 1 -> 5*60;
case 2 -> 30*60;
case 3 -> 3600;
case 4 -> 6*3600;
case 5 -> 24*3600;
case 6 -> 3*24*3600;
case 7 -> 7*24*3600;
default -> throw new IllegalStateException("Unexpected value: "+item.getItemId());
};
pollDurationView.setText(getString(R.string.compose_poll_duration, item.getTitle()));
return true;
});
menu.show();
}
private static class DraftMediaAttachment{
@ -484,4 +606,10 @@ public class ComposeFragment extends ToolbarFragment implements OnBackPressedLis
public View view;
public ProgressBar progressBar;
}
private static class DraftPollOption{
public EditText edit;
public View view;
public View dragger;
}
}

View File

@ -0,0 +1,121 @@
package org.joinmastodon.android.ui.views;
import android.content.Context;
import android.util.AttributeSet;
import android.util.Log;
import android.view.MotionEvent;
import android.view.View;
import android.view.ViewTreeObserver;
import android.widget.LinearLayout;
import androidx.annotation.Nullable;
import me.grishka.appkit.utils.CubicBezierInterpolator;
import me.grishka.appkit.utils.V;
public class ReorderableLinearLayout extends LinearLayout{
private static final String TAG="ReorderableLinearLayout";
private View draggedView;
private View bottomSibling, topSibling;
private float startY;
private OnDragListener dragListener;
public ReorderableLinearLayout(Context context){
super(context);
}
public ReorderableLinearLayout(Context context, @Nullable AttributeSet attrs){
super(context, attrs);
}
public ReorderableLinearLayout(Context context, @Nullable AttributeSet attrs, int defStyleAttr){
super(context, attrs, defStyleAttr);
}
public void startDragging(View child){
getParent().requestDisallowInterceptTouchEvent(true);
draggedView=child;
draggedView.animate().translationZ(V.dp(1f)).setDuration(150).setInterpolator(CubicBezierInterpolator.DEFAULT).start();
int index=indexOfChild(child);
if(index==-1)
throw new IllegalArgumentException("view "+child+" is not a child of this layout");
if(index>0)
topSibling=getChildAt(index-1);
if(index<getChildCount()-1)
bottomSibling=getChildAt(index+1);
}
@Override
public boolean onInterceptTouchEvent(MotionEvent ev){
if(draggedView!=null){
startY=ev.getY();
return true;
}
return false;
}
@Override
public boolean onTouchEvent(MotionEvent ev){
if(draggedView!=null){
if(ev.getAction()==MotionEvent.ACTION_UP || ev.getAction()==MotionEvent.ACTION_CANCEL){
endDrag();
draggedView=null;
bottomSibling=null;
topSibling=null;
}else if(ev.getAction()==MotionEvent.ACTION_MOVE){
draggedView.setTranslationY(ev.getY()-startY);
if(topSibling!=null && draggedView.getY()<=topSibling.getY()){
moveDraggedView(-1);
}else if(bottomSibling!=null && draggedView.getY()>=bottomSibling.getY()){
moveDraggedView(1);
}
}
}
return super.onTouchEvent(ev);
}
private void endDrag(){
draggedView.animate().translationY(0f).translationZ(0f).setDuration(200).setInterpolator(CubicBezierInterpolator.DEFAULT).start();
}
private void moveDraggedView(int positionOffset){
int index=indexOfChild(draggedView);
int prevTop=draggedView.getTop();
removeView(draggedView);
int prevIndex=index;
index+=positionOffset;
addView(draggedView, index);
final View prevSibling=positionOffset<0 ? topSibling : bottomSibling;
int prevSiblingTop=prevSibling.getTop();
if(index>0)
topSibling=getChildAt(index-1);
else
topSibling=null;
if(index<getChildCount()-1)
bottomSibling=getChildAt(index+1);
else
bottomSibling=null;
dragListener.onSwapItems(prevIndex, index);
draggedView.getViewTreeObserver().addOnPreDrawListener(new ViewTreeObserver.OnPreDrawListener(){
@Override
public boolean onPreDraw(){
draggedView.getViewTreeObserver().removeOnPreDrawListener(this);
float offset=prevTop-draggedView.getTop();
startY-=offset;
draggedView.setTranslationY(draggedView.getTranslationY()+offset);
prevSibling.setTranslationY(prevSiblingTop-prevSibling.getTop());
prevSibling.animate().translationY(0f).setInterpolator(CubicBezierInterpolator.DEFAULT).setDuration(200).start();
return true;
}
});
}
public void setDragListener(OnDragListener dragListener){
this.dragListener=dragListener;
}
public interface OnDragListener{
void onSwapItems(int oldIndex, int newIndex);
}
}

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="@color/gray_800" android:alpha="0.3" android:state_enabled="false"/>
<item android:color="@color/gray_800"/>
</selector>

View File

@ -0,0 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<shape xmlns:android="http://schemas.android.com/apk/res/android">
<solid android:color="@color/gray_100"/>
<corners android:radius="10dp"/>
</shape>

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 3.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.5zM2 12C2 6.477 6.477 2 12 2s10 4.477 10 10-4.477 10-10 10S2 17.523 2 12z" android:fillColor="@color/fluent_default_icon_tint"/>
</vector>

View File

@ -0,0 +1,42 @@
<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
android:orientation="horizontal"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:paddingBottom="8dp"
android:paddingStart="16dp"
android:clipToPadding="false">
<LinearLayout
android:layout_width="0dp"
android:layout_height="56dp"
android:layout_weight="1"
android:background="@drawable/bg_poll_option"
android:outlineProvider="background"
android:elevation="2dp">
<ImageView
android:layout_width="24dp"
android:layout_height="24dp"
android:layout_margin="16dp"
android:src="@drawable/ic_fluent_circle_24_regular"/>
<EditText
android:id="@+id/edit"
android:layout_width="0dp"
android:layout_height="match_parent"
android:layout_weight="1"
android:background="@null"
android:paddingStart="0dp"
android:paddingEnd="16dp"
android:textAppearance="@style/m3_title_medium"
android:inputType="textCapSentences"
android:singleLine="true"/>
</LinearLayout>
<ImageView
android:id="@+id/dragger_thingy"
android:layout_width="56dp"
android:layout_height="56dp"
android:scaleType="center"
android:src="@drawable/ic_fluent_re_order_dots_vertical_24_regular"/>
</LinearLayout>

View File

@ -81,6 +81,46 @@
android:background="@null"
android:inputType="textMultiLine|textCapSentences"/>
<LinearLayout
android:id="@+id/poll_wrap"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="vertical"
android:visibility="gone"
tools:visibility="visible">
<org.joinmastodon.android.ui.views.ReorderableLinearLayout
android:id="@+id/poll_options"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="vertical"/>
<LinearLayout
android:id="@+id/add_poll_option"
android:layout_width="match_parent"
android:layout_height="56dp"
android:layout_marginStart="16dp"
android:layout_marginEnd="56dp"
android:layout_marginBottom="8dp"
android:background="@drawable/bg_poll_option"
android:outlineProvider="background"
android:elevation="2dp">
<ImageView
android:layout_width="24dp"
android:layout_height="24dp"
android:layout_margin="16dp"
android:src="@drawable/ic_fluent_add_circle_24_regular"/>
</LinearLayout>
<TextView
android:id="@+id/poll_duration"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginLeft="16dp"
android:layout_marginRight="16dp"
android:layout_marginBottom="8dp"
android:textAppearance="@style/m3_label_large"
android:textColor="@color/gray_800"
tools:text="Duration: 7 days"/>
</LinearLayout>
<LinearLayout
android:id="@+id/attachments"
android:layout_width="match_parent"
@ -109,6 +149,8 @@
android:layout_marginEnd="24dp"
android:background="?android:attr/selectableItemBackgroundBorderless"
android:padding="0px"
android:tint="@color/compose_button"
android:tintMode="src_in"
android:src="@drawable/ic_fluent_image_24_regular"/>
<ImageButton
@ -118,6 +160,8 @@
android:layout_marginEnd="24dp"
android:background="?android:attr/selectableItemBackgroundBorderless"
android:padding="0px"
android:tint="@color/compose_button"
android:tintMode="src_in"
android:src="@drawable/ic_fluent_poll_24_selector"/>
<ImageButton
@ -127,6 +171,8 @@
android:layout_marginEnd="24dp"
android:background="?android:attr/selectableItemBackgroundBorderless"
android:padding="0px"
android:tint="@color/compose_button"
android:tintMode="src_in"
android:src="@drawable/ic_fluent_emoji_24_selector"/>
<ImageButton
@ -136,6 +182,8 @@
android:layout_marginEnd="24dp"
android:background="?android:attr/selectableItemBackgroundBorderless"
android:padding="0px"
android:tint="@color/compose_button"
android:tintMode="src_in"
android:src="@drawable/ic_fluent_chat_warning_24_selector"/>
<ImageButton
@ -145,6 +193,8 @@
android:layout_marginEnd="24dp"
android:background="?android:attr/selectableItemBackgroundBorderless"
android:padding="0px"
android:tint="@color/compose_button"
android:tintMode="src_in"
android:src="@drawable/ic_fluent_people_community_24_regular"/>
<Space

View File

@ -73,4 +73,18 @@
<string name="field_content">Content</string>
<string name="saving">Saving…</string>
<string name="post_from_user">Post from %s</string>
<string name="poll_option_hint">Option %d</string>
<plurals name="x_minutes">
<item quantity="one">%d minute</item>
<item quantity="other">%d minutes</item>
</plurals>
<plurals name="x_hours">
<item quantity="one">%d hour</item>
<item quantity="other">%d hours</item>
</plurals>
<plurals name="x_days">
<item quantity="one">%d day</item>
<item quantity="other">%d days</item>
</plurals>
<string name="compose_poll_duration">Duration: %s</string>
</resources>

View File

@ -16,6 +16,7 @@
<item name="android:buttonStyle">@style/Widget.Mastodon.Button</item>
<item name="android:alertDialogTheme">@style/Theme.Mastodon.Dialog.Alert</item>
<item name="appkitBackDrawable">@drawable/ic_fluent_arrow_left_24_regular</item>
<item name="android:splitMotionEvents">false</item>
</style>
<style name="Theme.Mastodon.Toolbar" parent="android:ThemeOverlay.Material.ActionBar">