Move media & poll stuff out of ComposeFragment to separate classes
This commit is contained in:
parent
642e96a439
commit
d3fe7857b7
File diff suppressed because it is too large
Load Diff
|
@ -1,44 +0,0 @@
|
||||||
package org.joinmastodon.android.ui;
|
|
||||||
|
|
||||||
import android.content.Context;
|
|
||||||
import android.util.AttributeSet;
|
|
||||||
import android.view.View;
|
|
||||||
|
|
||||||
import androidx.annotation.NonNull;
|
|
||||||
import androidx.recyclerview.widget.GridLayoutManager;
|
|
||||||
import androidx.recyclerview.widget.RecyclerView;
|
|
||||||
|
|
||||||
public class TileGridLayoutManager extends GridLayoutManager{
|
|
||||||
private static final String TAG="TileGridLayoutManager";
|
|
||||||
private int lastWidth=0;
|
|
||||||
|
|
||||||
public TileGridLayoutManager(Context context, AttributeSet attrs, int defStyleAttr, int defStyleRes){
|
|
||||||
super(context, attrs, defStyleAttr, defStyleRes);
|
|
||||||
}
|
|
||||||
|
|
||||||
public TileGridLayoutManager(Context context, int spanCount){
|
|
||||||
super(context, spanCount);
|
|
||||||
}
|
|
||||||
|
|
||||||
public TileGridLayoutManager(Context context, int spanCount, int orientation, boolean reverseLayout){
|
|
||||||
super(context, spanCount, orientation, reverseLayout);
|
|
||||||
}
|
|
||||||
|
|
||||||
@Override
|
|
||||||
public int getColumnCountForAccessibility(RecyclerView.Recycler recycler, RecyclerView.State state){
|
|
||||||
return 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
@Override
|
|
||||||
public void onMeasure(@NonNull RecyclerView.Recycler recycler, @NonNull RecyclerView.State state, int widthSpec, int heightSpec){
|
|
||||||
int width=View.MeasureSpec.getSize(widthSpec);
|
|
||||||
// Is there a better way to invalidate item decorations when the size changes?
|
|
||||||
if(lastWidth!=width){
|
|
||||||
lastWidth=width;
|
|
||||||
if(getChildCount()>0){
|
|
||||||
((RecyclerView)getChildAt(0).getParent()).invalidateItemDecorations();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
super.onMeasure(recycler, state, widthSpec, heightSpec);
|
|
||||||
}
|
|
||||||
}
|
|
|
@ -1,11 +1,9 @@
|
||||||
package org.joinmastodon.android.ui;
|
package org.joinmastodon.android.ui.viewcontrollers;
|
||||||
|
|
||||||
import android.annotation.SuppressLint;
|
import android.annotation.SuppressLint;
|
||||||
import android.app.Activity;
|
import android.app.Activity;
|
||||||
import android.graphics.Rect;
|
import android.graphics.Rect;
|
||||||
import android.graphics.Typeface;
|
|
||||||
import android.graphics.drawable.Drawable;
|
import android.graphics.drawable.Drawable;
|
||||||
import android.text.TextUtils;
|
|
||||||
import android.view.Gravity;
|
import android.view.Gravity;
|
||||||
import android.view.View;
|
import android.view.View;
|
||||||
import android.view.ViewGroup;
|
import android.view.ViewGroup;
|
||||||
|
@ -21,6 +19,8 @@ import org.joinmastodon.android.model.Account;
|
||||||
import org.joinmastodon.android.model.Emoji;
|
import org.joinmastodon.android.model.Emoji;
|
||||||
import org.joinmastodon.android.model.Hashtag;
|
import org.joinmastodon.android.model.Hashtag;
|
||||||
import org.joinmastodon.android.model.SearchResults;
|
import org.joinmastodon.android.model.SearchResults;
|
||||||
|
import org.joinmastodon.android.ui.BetterItemAnimator;
|
||||||
|
import org.joinmastodon.android.ui.OutlineProviders;
|
||||||
import org.joinmastodon.android.ui.text.HtmlParser;
|
import org.joinmastodon.android.ui.text.HtmlParser;
|
||||||
import org.joinmastodon.android.ui.utils.CustomEmojiHelper;
|
import org.joinmastodon.android.ui.utils.CustomEmojiHelper;
|
||||||
import org.joinmastodon.android.ui.utils.UiUtils;
|
import org.joinmastodon.android.ui.utils.UiUtils;
|
|
@ -0,0 +1,760 @@
|
||||||
|
package org.joinmastodon.android.ui.viewcontrollers;
|
||||||
|
|
||||||
|
import android.animation.Animator;
|
||||||
|
import android.animation.AnimatorListenerAdapter;
|
||||||
|
import android.animation.AnimatorSet;
|
||||||
|
import android.animation.ObjectAnimator;
|
||||||
|
import android.content.Context;
|
||||||
|
import android.database.Cursor;
|
||||||
|
import android.graphics.Bitmap;
|
||||||
|
import android.graphics.drawable.ColorDrawable;
|
||||||
|
import android.graphics.drawable.Drawable;
|
||||||
|
import android.media.MediaMetadataRetriever;
|
||||||
|
import android.net.Uri;
|
||||||
|
import android.os.Build;
|
||||||
|
import android.os.Bundle;
|
||||||
|
import android.os.Parcelable;
|
||||||
|
import android.provider.OpenableColumns;
|
||||||
|
import android.text.TextUtils;
|
||||||
|
import android.util.Log;
|
||||||
|
import android.view.View;
|
||||||
|
import android.view.ViewGroup;
|
||||||
|
import android.widget.HorizontalScrollView;
|
||||||
|
import android.widget.ImageButton;
|
||||||
|
import android.widget.ImageView;
|
||||||
|
import android.widget.LinearLayout;
|
||||||
|
import android.widget.ProgressBar;
|
||||||
|
import android.widget.TextView;
|
||||||
|
import android.widget.Toast;
|
||||||
|
|
||||||
|
import org.joinmastodon.android.MastodonApp;
|
||||||
|
import org.joinmastodon.android.R;
|
||||||
|
import org.joinmastodon.android.api.MastodonAPIController;
|
||||||
|
import org.joinmastodon.android.api.ProgressListener;
|
||||||
|
import org.joinmastodon.android.api.requests.statuses.GetAttachmentByID;
|
||||||
|
import org.joinmastodon.android.api.requests.statuses.UpdateAttachment;
|
||||||
|
import org.joinmastodon.android.api.requests.statuses.UploadAttachment;
|
||||||
|
import org.joinmastodon.android.fragments.ComposeFragment;
|
||||||
|
import org.joinmastodon.android.fragments.ComposeImageDescriptionFragment;
|
||||||
|
import org.joinmastodon.android.model.Attachment;
|
||||||
|
import org.joinmastodon.android.model.Instance;
|
||||||
|
import org.joinmastodon.android.ui.OutlineProviders;
|
||||||
|
import org.joinmastodon.android.ui.drawables.EmptyDrawable;
|
||||||
|
import org.joinmastodon.android.ui.utils.UiUtils;
|
||||||
|
import org.joinmastodon.android.ui.views.ReorderableLinearLayout;
|
||||||
|
import org.joinmastodon.android.utils.TransferSpeedTracker;
|
||||||
|
import org.parceler.Parcel;
|
||||||
|
import org.parceler.Parcels;
|
||||||
|
|
||||||
|
import java.util.ArrayList;
|
||||||
|
import java.util.HashMap;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Locale;
|
||||||
|
import java.util.Objects;
|
||||||
|
import java.util.function.Consumer;
|
||||||
|
import java.util.stream.Collectors;
|
||||||
|
|
||||||
|
import me.grishka.appkit.Nav;
|
||||||
|
import me.grishka.appkit.api.Callback;
|
||||||
|
import me.grishka.appkit.api.ErrorResponse;
|
||||||
|
import me.grishka.appkit.imageloader.ViewImageLoader;
|
||||||
|
import me.grishka.appkit.imageloader.requests.UrlImageLoaderRequest;
|
||||||
|
import me.grishka.appkit.utils.CubicBezierInterpolator;
|
||||||
|
import me.grishka.appkit.utils.V;
|
||||||
|
|
||||||
|
public class ComposeMediaViewController{
|
||||||
|
private static final int MAX_ATTACHMENTS=4;
|
||||||
|
private static final String TAG="ComposeMediaViewControl";
|
||||||
|
|
||||||
|
private final ComposeFragment fragment;
|
||||||
|
|
||||||
|
private ReorderableLinearLayout attachmentsView;
|
||||||
|
private HorizontalScrollView attachmentsScroller;
|
||||||
|
|
||||||
|
private ArrayList<DraftMediaAttachment> attachments=new ArrayList<>();
|
||||||
|
private boolean attachmentsErrorShowing;
|
||||||
|
|
||||||
|
public ComposeMediaViewController(ComposeFragment fragment){
|
||||||
|
this.fragment=fragment;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setView(View view, Bundle savedInstanceState){
|
||||||
|
attachmentsView=view.findViewById(R.id.attachments);
|
||||||
|
attachmentsScroller=view.findViewById(R.id.attachments_scroller);
|
||||||
|
attachmentsView.setDividerDrawable(new EmptyDrawable(V.dp(8), 0));
|
||||||
|
attachmentsView.setDragListener(new AttachmentDragListener());
|
||||||
|
attachmentsView.setMoveInBothDimensions(true);
|
||||||
|
|
||||||
|
if(!fragment.getWasDetached() && savedInstanceState!=null && savedInstanceState.containsKey("attachments")){
|
||||||
|
ArrayList<Parcelable> serializedAttachments=savedInstanceState.getParcelableArrayList("attachments");
|
||||||
|
for(Parcelable a:serializedAttachments){
|
||||||
|
DraftMediaAttachment att=Parcels.unwrap(a);
|
||||||
|
attachmentsView.addView(createMediaAttachmentView(att));
|
||||||
|
attachments.add(att);
|
||||||
|
}
|
||||||
|
attachmentsScroller.setVisibility(View.VISIBLE);
|
||||||
|
updateMediaAttachmentsLayout();
|
||||||
|
}else if(!attachments.isEmpty()){
|
||||||
|
attachmentsScroller.setVisibility(View.VISIBLE);
|
||||||
|
for(DraftMediaAttachment att:attachments){
|
||||||
|
attachmentsView.addView(createMediaAttachmentView(att));
|
||||||
|
}
|
||||||
|
updateMediaAttachmentsLayout();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public void onViewCreated(Bundle savedInstanceState){
|
||||||
|
if(savedInstanceState==null && !fragment.editingStatus.mediaAttachments.isEmpty()){
|
||||||
|
attachmentsScroller.setVisibility(View.VISIBLE);
|
||||||
|
for(Attachment att:fragment.editingStatus.mediaAttachments){
|
||||||
|
DraftMediaAttachment da=new DraftMediaAttachment();
|
||||||
|
da.serverAttachment=att;
|
||||||
|
da.description=att.description;
|
||||||
|
da.uri=att.previewUrl!=null ? Uri.parse(att.previewUrl) : null;
|
||||||
|
da.state=AttachmentUploadState.DONE;
|
||||||
|
attachmentsView.addView(createMediaAttachmentView(da));
|
||||||
|
attachments.add(da);
|
||||||
|
}
|
||||||
|
updateMediaAttachmentsLayout();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public boolean addMediaAttachment(Uri uri, String description){
|
||||||
|
if(getMediaAttachmentsCount()==MAX_ATTACHMENTS){
|
||||||
|
showMediaAttachmentError(fragment.getResources().getQuantityString(R.plurals.cant_add_more_than_x_attachments, MAX_ATTACHMENTS, MAX_ATTACHMENTS));
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
String type=fragment.getActivity().getContentResolver().getType(uri);
|
||||||
|
int size;
|
||||||
|
try(Cursor cursor=MastodonApp.context.getContentResolver().query(uri, new String[]{OpenableColumns.SIZE}, null, null, null)){
|
||||||
|
cursor.moveToFirst();
|
||||||
|
size=cursor.getInt(0);
|
||||||
|
}catch(Exception x){
|
||||||
|
Log.w("ComposeFragment", x);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
Instance instance=fragment.instance;
|
||||||
|
if(instance!=null && instance.configuration!=null && instance.configuration.mediaAttachments!=null){
|
||||||
|
if(instance.configuration.mediaAttachments.supportedMimeTypes!=null && !instance.configuration.mediaAttachments.supportedMimeTypes.contains(type)){
|
||||||
|
showMediaAttachmentError(fragment.getString(R.string.media_attachment_unsupported_type, UiUtils.getFileName(uri)));
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if(!type.startsWith("image/")){
|
||||||
|
int sizeLimit=instance.configuration.mediaAttachments.videoSizeLimit;
|
||||||
|
if(size>sizeLimit){
|
||||||
|
float mb=sizeLimit/(float) (1024*1024);
|
||||||
|
String sMb=String.format(Locale.getDefault(), mb%1f==0f ? "%.0f" : "%.2f", mb);
|
||||||
|
showMediaAttachmentError(fragment.getString(R.string.media_attachment_too_big, UiUtils.getFileName(uri), sMb));
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
DraftMediaAttachment draft=new DraftMediaAttachment();
|
||||||
|
draft.uri=uri;
|
||||||
|
draft.mimeType=type;
|
||||||
|
draft.description=description;
|
||||||
|
draft.fileSize=size;
|
||||||
|
|
||||||
|
UiUtils.beginLayoutTransition(attachmentsScroller);
|
||||||
|
attachmentsView.addView(createMediaAttachmentView(draft), new LinearLayout.LayoutParams(0, ViewGroup.LayoutParams.MATCH_PARENT));
|
||||||
|
attachments.add(draft);
|
||||||
|
attachmentsScroller.setVisibility(View.VISIBLE);
|
||||||
|
updateMediaAttachmentsLayout();
|
||||||
|
// draft.setOverlayVisible(true, false);
|
||||||
|
|
||||||
|
if(!areThereAnyUploadingAttachments()){
|
||||||
|
uploadNextQueuedAttachment();
|
||||||
|
}
|
||||||
|
fragment.updatePublishButtonState();
|
||||||
|
fragment.updateMediaPollStates();
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
private void updateMediaAttachmentsLayout(){
|
||||||
|
int newWidth=attachments.size()>2 ? ViewGroup.LayoutParams.WRAP_CONTENT : ViewGroup.LayoutParams.MATCH_PARENT;
|
||||||
|
if(newWidth!=attachmentsView.getLayoutParams().width){
|
||||||
|
attachmentsView.getLayoutParams().width=newWidth;
|
||||||
|
attachmentsScroller.requestLayout();
|
||||||
|
}
|
||||||
|
for(DraftMediaAttachment att:attachments){
|
||||||
|
LinearLayout.LayoutParams lp=(LinearLayout.LayoutParams) att.view.getLayoutParams();
|
||||||
|
if(attachments.size()<3){
|
||||||
|
lp.width=0;
|
||||||
|
lp.weight=1f;
|
||||||
|
}else{
|
||||||
|
lp.width=V.dp(200);
|
||||||
|
lp.weight=0f;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void showMediaAttachmentError(String text){
|
||||||
|
if(!attachmentsErrorShowing){
|
||||||
|
Toast.makeText(fragment.getActivity(), text, Toast.LENGTH_SHORT).show();
|
||||||
|
attachmentsErrorShowing=true;
|
||||||
|
attachmentsView.postDelayed(()->attachmentsErrorShowing=false, 2000);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private View createMediaAttachmentView(DraftMediaAttachment draft){
|
||||||
|
View thumb=fragment.getActivity().getLayoutInflater().inflate(R.layout.compose_media_thumb, attachmentsView, false);
|
||||||
|
ImageView img=thumb.findViewById(R.id.thumb);
|
||||||
|
if(draft.serverAttachment!=null){
|
||||||
|
if(draft.serverAttachment.previewUrl!=null)
|
||||||
|
ViewImageLoader.load(img, draft.serverAttachment.blurhashPlaceholder, new UrlImageLoaderRequest(draft.serverAttachment.previewUrl, V.dp(250), V.dp(250)));
|
||||||
|
}else{
|
||||||
|
if(draft.mimeType.startsWith("image/")){
|
||||||
|
ViewImageLoader.load(img, null, new UrlImageLoaderRequest(draft.uri, V.dp(250), V.dp(250)));
|
||||||
|
}else if(draft.mimeType.startsWith("video/")){
|
||||||
|
loadVideoThumbIntoView(img, draft.uri);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
draft.view=thumb;
|
||||||
|
draft.imageView=img;
|
||||||
|
draft.progressBar=thumb.findViewById(R.id.progress);
|
||||||
|
draft.titleView=thumb.findViewById(R.id.title);
|
||||||
|
draft.subtitleView=thumb.findViewById(R.id.subtitle);
|
||||||
|
draft.removeButton=thumb.findViewById(R.id.delete);
|
||||||
|
draft.editButton=thumb.findViewById(R.id.edit);
|
||||||
|
draft.dragLayer=thumb.findViewById(R.id.drag_layer);
|
||||||
|
|
||||||
|
draft.removeButton.setTag(draft);
|
||||||
|
draft.removeButton.setOnClickListener(this::onRemoveMediaAttachmentClick);
|
||||||
|
draft.editButton.setTag(draft);
|
||||||
|
|
||||||
|
thumb.setOutlineProvider(OutlineProviders.roundedRect(12));
|
||||||
|
thumb.setClipToOutline(true);
|
||||||
|
img.setOutlineProvider(OutlineProviders.roundedRect(12));
|
||||||
|
img.setClipToOutline(true);
|
||||||
|
|
||||||
|
thumb.setBackgroundColor(UiUtils.getThemeColor(fragment.getActivity(), R.attr.colorM3Surface));
|
||||||
|
thumb.setOnLongClickListener(v->{
|
||||||
|
if(!v.hasTransientState() && attachments.size()>1){
|
||||||
|
attachmentsView.startDragging(v);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
});
|
||||||
|
thumb.setTag(draft);
|
||||||
|
|
||||||
|
|
||||||
|
if(draft.fileSize>0){
|
||||||
|
int subtitleRes=switch(Objects.requireNonNullElse(draft.mimeType, "").split("/")[0]){
|
||||||
|
case "image" -> R.string.attachment_description_image;
|
||||||
|
case "video" -> R.string.attachment_description_video;
|
||||||
|
case "audio" -> R.string.attachment_description_audio;
|
||||||
|
default -> R.string.attachment_description_unknown;
|
||||||
|
};
|
||||||
|
draft.subtitleView.setText(fragment.getString(subtitleRes, UiUtils.formatFileSize(fragment.getActivity(), draft.fileSize, true)));
|
||||||
|
}else if(draft.serverAttachment!=null){
|
||||||
|
int subtitleRes=switch(draft.serverAttachment.type){
|
||||||
|
case IMAGE -> R.string.attachment_type_image;
|
||||||
|
case VIDEO -> R.string.attachment_type_video;
|
||||||
|
case GIFV -> R.string.attachment_type_gif;
|
||||||
|
case AUDIO -> R.string.attachment_type_audio;
|
||||||
|
case UNKNOWN -> R.string.attachment_type_unknown;
|
||||||
|
};
|
||||||
|
draft.subtitleView.setText(subtitleRes);
|
||||||
|
}
|
||||||
|
draft.titleView.setText(fragment.getString(R.string.attachment_x_percent_uploaded, 0));
|
||||||
|
draft.removeButton.setImageResource(R.drawable.ic_baseline_close_24);
|
||||||
|
|
||||||
|
if(draft.state==AttachmentUploadState.ERROR){
|
||||||
|
draft.titleView.setText(R.string.upload_failed);
|
||||||
|
draft.editButton.setImageResource(R.drawable.ic_restart_alt_24px);
|
||||||
|
draft.editButton.setOnClickListener(this::onRetryOrCancelMediaUploadClick);
|
||||||
|
draft.progressBar.setVisibility(View.GONE);
|
||||||
|
draft.setUseErrorColors(true);
|
||||||
|
}else if(draft.state==AttachmentUploadState.DONE){
|
||||||
|
draft.setDescriptionToTitle();
|
||||||
|
draft.progressBar.setVisibility(View.GONE);
|
||||||
|
draft.editButton.setOnClickListener(this::onEditMediaDescriptionClick);
|
||||||
|
}else{
|
||||||
|
draft.editButton.setVisibility(View.GONE);
|
||||||
|
draft.removeButton.setImageResource(R.drawable.ic_baseline_close_24);
|
||||||
|
if(draft.state==AttachmentUploadState.PROCESSING){
|
||||||
|
draft.titleView.setText(R.string.upload_processing);
|
||||||
|
}else{
|
||||||
|
draft.titleView.setText(fragment.getString(R.string.attachment_x_percent_uploaded, 0));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return thumb;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void addFakeMediaAttachment(Uri uri, String description){
|
||||||
|
DraftMediaAttachment draft=new DraftMediaAttachment();
|
||||||
|
draft.uri=uri;
|
||||||
|
draft.description=description;
|
||||||
|
attachmentsView.addView(createMediaAttachmentView(draft));
|
||||||
|
attachments.add(draft);
|
||||||
|
attachmentsScroller.setVisibility(View.VISIBLE);
|
||||||
|
updateMediaAttachmentsLayout();
|
||||||
|
}
|
||||||
|
|
||||||
|
private void uploadMediaAttachment(DraftMediaAttachment attachment){
|
||||||
|
if(areThereAnyUploadingAttachments()){
|
||||||
|
throw new IllegalStateException("there is already an attachment being uploaded");
|
||||||
|
}
|
||||||
|
attachment.state=AttachmentUploadState.UPLOADING;
|
||||||
|
attachment.progressBar.setVisibility(View.VISIBLE);
|
||||||
|
int maxSize=0;
|
||||||
|
String contentType=fragment.getActivity().getContentResolver().getType(attachment.uri);
|
||||||
|
if(contentType!=null && contentType.startsWith("image/")){
|
||||||
|
maxSize=2_073_600; // TODO get this from instance configuration when it gets added there
|
||||||
|
}
|
||||||
|
attachment.progressBar.setProgress(0);
|
||||||
|
attachment.speedTracker.reset();
|
||||||
|
attachment.speedTracker.addSample(0);
|
||||||
|
attachment.uploadRequest=(UploadAttachment) new UploadAttachment(attachment.uri, maxSize, attachment.description)
|
||||||
|
.setProgressListener(new ProgressListener(){
|
||||||
|
@Override
|
||||||
|
public void onProgress(long transferred, long total){
|
||||||
|
float progressFraction=transferred/(float)total;
|
||||||
|
int progress=Math.round(progressFraction*attachment.progressBar.getMax());
|
||||||
|
if(Build.VERSION.SDK_INT>=24)
|
||||||
|
attachment.progressBar.setProgress(progress, true);
|
||||||
|
else
|
||||||
|
attachment.progressBar.setProgress(progress);
|
||||||
|
|
||||||
|
attachment.titleView.setText(fragment.getString(R.string.attachment_x_percent_uploaded, Math.round(progressFraction*100f)));
|
||||||
|
|
||||||
|
attachment.speedTracker.setTotalBytes(total);
|
||||||
|
// attachment.uploadStateTitle.setText(fragment.getString(R.string.file_upload_progress, UiUtils.formatFileSize(fragment.getActivity(), transferred, true), UiUtils.formatFileSize(fragment.getActivity(), total, true)));
|
||||||
|
attachment.speedTracker.addSample(transferred);
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.setCallback(new Callback<>(){
|
||||||
|
@Override
|
||||||
|
public void onSuccess(Attachment result){
|
||||||
|
attachment.serverAttachment=result;
|
||||||
|
if(TextUtils.isEmpty(result.url)){
|
||||||
|
attachment.state=AttachmentUploadState.PROCESSING;
|
||||||
|
attachment.processingPollingRunnable=()->pollForMediaAttachmentProcessing(attachment);
|
||||||
|
if(fragment.getActivity()==null)
|
||||||
|
return;
|
||||||
|
attachment.titleView.setText(R.string.upload_processing);
|
||||||
|
UiUtils.runOnUiThread(attachment.processingPollingRunnable, 1000);
|
||||||
|
if(!areThereAnyUploadingAttachments())
|
||||||
|
uploadNextQueuedAttachment();
|
||||||
|
}else{
|
||||||
|
finishMediaAttachmentUpload(attachment);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void onError(ErrorResponse error){
|
||||||
|
attachment.uploadRequest=null;
|
||||||
|
attachment.state=AttachmentUploadState.ERROR;
|
||||||
|
attachment.titleView.setText(R.string.upload_failed);
|
||||||
|
// if(error instanceof MastodonErrorResponse er){
|
||||||
|
// if(er.underlyingException instanceof SocketException || er.underlyingException instanceof UnknownHostException || er.underlyingException instanceof InterruptedIOException)
|
||||||
|
// attachment.uploadStateText.setText(R.string.upload_error_connection_lost);
|
||||||
|
// else
|
||||||
|
// attachment.uploadStateText.setText(er.error);
|
||||||
|
// }else{
|
||||||
|
// attachment.uploadStateText.setText("");
|
||||||
|
// }
|
||||||
|
// attachment.retryButton.setImageResource(R.drawable.ic_fluent_arrow_clockwise_24_filled);
|
||||||
|
// attachment.retryButton.setContentDescription(fragment.getString(R.string.retry_upload));
|
||||||
|
|
||||||
|
V.setVisibilityAnimated(attachment.editButton, View.VISIBLE);
|
||||||
|
attachment.editButton.setImageResource(R.drawable.ic_restart_alt_24px);
|
||||||
|
attachment.editButton.setOnClickListener(ComposeMediaViewController.this::onRetryOrCancelMediaUploadClick);
|
||||||
|
attachment.setUseErrorColors(true);
|
||||||
|
V.setVisibilityAnimated(attachment.progressBar, View.GONE);
|
||||||
|
|
||||||
|
if(!areThereAnyUploadingAttachments())
|
||||||
|
uploadNextQueuedAttachment();
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.exec(fragment.getAccountID());
|
||||||
|
}
|
||||||
|
|
||||||
|
private void onRemoveMediaAttachmentClick(View v){
|
||||||
|
DraftMediaAttachment att=(DraftMediaAttachment) v.getTag();
|
||||||
|
if(att.isUploadingOrProcessing())
|
||||||
|
att.cancelUpload();
|
||||||
|
attachments.remove(att);
|
||||||
|
if(!areThereAnyUploadingAttachments())
|
||||||
|
uploadNextQueuedAttachment();
|
||||||
|
if(!attachments.isEmpty())
|
||||||
|
UiUtils.beginLayoutTransition(attachmentsScroller);
|
||||||
|
attachmentsView.removeView(att.view);
|
||||||
|
if(getMediaAttachmentsCount()==0){
|
||||||
|
attachmentsScroller.setVisibility(View.GONE);
|
||||||
|
}else{
|
||||||
|
updateMediaAttachmentsLayout();
|
||||||
|
}
|
||||||
|
fragment.updatePublishButtonState();
|
||||||
|
fragment.updateMediaPollStates();
|
||||||
|
}
|
||||||
|
|
||||||
|
private void onRetryOrCancelMediaUploadClick(View v){
|
||||||
|
DraftMediaAttachment att=(DraftMediaAttachment) v.getTag();
|
||||||
|
if(att.state==AttachmentUploadState.ERROR){
|
||||||
|
// att.retryButton.setImageResource(R.drawable.ic_fluent_dismiss_24_filled);
|
||||||
|
// att.retryButton.setContentDescription(fragment.getString(R.string.cancel));
|
||||||
|
V.setVisibilityAnimated(att.progressBar, View.VISIBLE);
|
||||||
|
V.setVisibilityAnimated(att.editButton, View.GONE);
|
||||||
|
att.titleView.setText(fragment.getString(R.string.attachment_x_percent_uploaded, 0));
|
||||||
|
att.state=AttachmentUploadState.QUEUED;
|
||||||
|
att.setUseErrorColors(false);
|
||||||
|
if(!areThereAnyUploadingAttachments()){
|
||||||
|
uploadNextQueuedAttachment();
|
||||||
|
}
|
||||||
|
}else{
|
||||||
|
onRemoveMediaAttachmentClick(v);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void loadVideoThumbIntoView(ImageView target, Uri uri){
|
||||||
|
MastodonAPIController.runInBackground(()->{
|
||||||
|
Context context=fragment.getActivity();
|
||||||
|
if(context==null)
|
||||||
|
return;
|
||||||
|
try{
|
||||||
|
MediaMetadataRetriever mmr=new MediaMetadataRetriever();
|
||||||
|
mmr.setDataSource(context, uri);
|
||||||
|
Bitmap frame=mmr.getFrameAtTime(3_000_000);
|
||||||
|
mmr.release();
|
||||||
|
int size=Math.max(frame.getWidth(), frame.getHeight());
|
||||||
|
int maxSize=V.dp(250);
|
||||||
|
if(size>maxSize){
|
||||||
|
float factor=maxSize/(float)size;
|
||||||
|
frame=Bitmap.createScaledBitmap(frame, Math.round(frame.getWidth()*factor), Math.round(frame.getHeight()*factor), true);
|
||||||
|
}
|
||||||
|
Bitmap finalFrame=frame;
|
||||||
|
target.post(()->target.setImageBitmap(finalFrame));
|
||||||
|
}catch(Exception x){
|
||||||
|
Log.w(TAG, "loadVideoThumbIntoView: error getting video frame", x);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private void pollForMediaAttachmentProcessing(DraftMediaAttachment attachment){
|
||||||
|
attachment.processingPollingRequest=(GetAttachmentByID) new GetAttachmentByID(attachment.serverAttachment.id)
|
||||||
|
.setCallback(new Callback<>(){
|
||||||
|
@Override
|
||||||
|
public void onSuccess(Attachment result){
|
||||||
|
attachment.processingPollingRequest=null;
|
||||||
|
if(!TextUtils.isEmpty(result.url)){
|
||||||
|
attachment.processingPollingRunnable=null;
|
||||||
|
attachment.serverAttachment=result;
|
||||||
|
finishMediaAttachmentUpload(attachment);
|
||||||
|
}else if(fragment.getActivity()!=null){
|
||||||
|
UiUtils.runOnUiThread(attachment.processingPollingRunnable, 1000);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void onError(ErrorResponse error){
|
||||||
|
attachment.processingPollingRequest=null;
|
||||||
|
if(fragment.getActivity()!=null)
|
||||||
|
UiUtils.runOnUiThread(attachment.processingPollingRunnable, 1000);
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.exec(fragment.getAccountID());
|
||||||
|
}
|
||||||
|
|
||||||
|
private void finishMediaAttachmentUpload(DraftMediaAttachment attachment){
|
||||||
|
if(attachment.state!=AttachmentUploadState.PROCESSING && attachment.state!=AttachmentUploadState.UPLOADING)
|
||||||
|
throw new IllegalStateException("Unexpected state "+attachment.state);
|
||||||
|
attachment.uploadRequest=null;
|
||||||
|
attachment.state=AttachmentUploadState.DONE;
|
||||||
|
attachment.editButton.setImageResource(R.drawable.ic_edit_24px);
|
||||||
|
attachment.removeButton.setImageResource(R.drawable.ic_delete_24px);
|
||||||
|
attachment.editButton.setOnClickListener(this::onEditMediaDescriptionClick);
|
||||||
|
V.setVisibilityAnimated(attachment.progressBar, View.GONE);
|
||||||
|
V.setVisibilityAnimated(attachment.editButton, View.VISIBLE);
|
||||||
|
attachment.setDescriptionToTitle();
|
||||||
|
if(!areThereAnyUploadingAttachments())
|
||||||
|
uploadNextQueuedAttachment();
|
||||||
|
fragment.updatePublishButtonState();
|
||||||
|
}
|
||||||
|
|
||||||
|
private void uploadNextQueuedAttachment(){
|
||||||
|
for(DraftMediaAttachment att:attachments){
|
||||||
|
if(att.state==AttachmentUploadState.QUEUED){
|
||||||
|
uploadMediaAttachment(att);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public boolean areThereAnyUploadingAttachments(){
|
||||||
|
for(DraftMediaAttachment att:attachments){
|
||||||
|
if(att.state==AttachmentUploadState.UPLOADING)
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
private void onEditMediaDescriptionClick(View v){
|
||||||
|
DraftMediaAttachment att=(DraftMediaAttachment) v.getTag();
|
||||||
|
if(att.serverAttachment==null)
|
||||||
|
return;
|
||||||
|
Bundle args=new Bundle();
|
||||||
|
args.putString("account", fragment.getAccountID());
|
||||||
|
args.putString("attachment", att.serverAttachment.id);
|
||||||
|
args.putParcelable("uri", att.uri);
|
||||||
|
args.putString("existingDescription", att.description);
|
||||||
|
args.putString("attachmentType", att.serverAttachment.type.toString());
|
||||||
|
Drawable img=att.imageView.getDrawable();
|
||||||
|
if(img!=null){
|
||||||
|
args.putInt("width", img.getIntrinsicWidth());
|
||||||
|
args.putInt("height", img.getIntrinsicHeight());
|
||||||
|
}
|
||||||
|
Nav.goForResult(fragment.getActivity(), ComposeImageDescriptionFragment.class, args, ComposeFragment.IMAGE_DESCRIPTION_RESULT, fragment);
|
||||||
|
}
|
||||||
|
|
||||||
|
public int getMediaAttachmentsCount(){
|
||||||
|
return attachments.size();
|
||||||
|
}
|
||||||
|
|
||||||
|
public void cancelAllUploads(){
|
||||||
|
for(DraftMediaAttachment att:attachments){
|
||||||
|
if(att.isUploadingOrProcessing())
|
||||||
|
att.cancelUpload();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setAltTextByID(String attID, String text){
|
||||||
|
for(DraftMediaAttachment att:attachments){
|
||||||
|
if(att.serverAttachment.id.equals(attID)){
|
||||||
|
att.descriptionSaved=false;
|
||||||
|
att.description=text;
|
||||||
|
att.setDescriptionToTitle();
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public List<String> getAttachmentIDs(){
|
||||||
|
return attachments.stream().map(a->a.serverAttachment.id).collect(Collectors.toList());
|
||||||
|
}
|
||||||
|
|
||||||
|
public boolean isEmpty(){
|
||||||
|
return attachments.isEmpty();
|
||||||
|
}
|
||||||
|
|
||||||
|
public boolean canAddMoreAttachments(){
|
||||||
|
return attachments.size()<MAX_ATTACHMENTS;
|
||||||
|
}
|
||||||
|
|
||||||
|
public int getMaxAttachments(){
|
||||||
|
return MAX_ATTACHMENTS;
|
||||||
|
}
|
||||||
|
|
||||||
|
public int getNonDoneAttachmentCount(){
|
||||||
|
int nonDoneAttachmentCount=0;
|
||||||
|
for(DraftMediaAttachment att:attachments){
|
||||||
|
if(att.state!=AttachmentUploadState.DONE)
|
||||||
|
nonDoneAttachmentCount++;
|
||||||
|
}
|
||||||
|
return nonDoneAttachmentCount;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void saveAltTextsBeforePublishing(Runnable onSuccess, Consumer<ErrorResponse> onError){
|
||||||
|
ArrayList<UpdateAttachment> updateAltTextRequests=new ArrayList<>();
|
||||||
|
for(DraftMediaAttachment att:attachments){
|
||||||
|
if(!att.descriptionSaved){
|
||||||
|
UpdateAttachment req=new UpdateAttachment(att.serverAttachment.id, att.description);
|
||||||
|
req.setCallback(new Callback<>(){
|
||||||
|
@Override
|
||||||
|
public void onSuccess(Attachment result){
|
||||||
|
att.descriptionSaved=true;
|
||||||
|
att.serverAttachment=result;
|
||||||
|
updateAltTextRequests.remove(req);
|
||||||
|
if(updateAltTextRequests.isEmpty())
|
||||||
|
onSuccess.run();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void onError(ErrorResponse error){
|
||||||
|
onError.accept(error);
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.exec(fragment.getAccountID());
|
||||||
|
updateAltTextRequests.add(req);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if(updateAltTextRequests.isEmpty())
|
||||||
|
onSuccess.run();
|
||||||
|
}
|
||||||
|
|
||||||
|
public void onSaveInstanceState(Bundle outState){
|
||||||
|
if(!attachments.isEmpty()){
|
||||||
|
ArrayList<Parcelable> serializedAttachments=new ArrayList<>(attachments.size());
|
||||||
|
for(DraftMediaAttachment att:attachments){
|
||||||
|
serializedAttachments.add(Parcels.wrap(att));
|
||||||
|
}
|
||||||
|
outState.putParcelableArrayList("attachments", serializedAttachments);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Parcel
|
||||||
|
static class DraftMediaAttachment{
|
||||||
|
public Attachment serverAttachment;
|
||||||
|
public Uri uri;
|
||||||
|
public transient UploadAttachment uploadRequest;
|
||||||
|
public transient GetAttachmentByID processingPollingRequest;
|
||||||
|
public String description;
|
||||||
|
public String mimeType;
|
||||||
|
public AttachmentUploadState state=AttachmentUploadState.QUEUED;
|
||||||
|
public int fileSize;
|
||||||
|
public boolean descriptionSaved=true;
|
||||||
|
|
||||||
|
public transient View view;
|
||||||
|
public transient ProgressBar progressBar;
|
||||||
|
public transient ImageButton removeButton, editButton;
|
||||||
|
public transient Runnable processingPollingRunnable;
|
||||||
|
public transient ImageView imageView;
|
||||||
|
public transient TextView titleView, subtitleView;
|
||||||
|
public transient TransferSpeedTracker speedTracker=new TransferSpeedTracker();
|
||||||
|
private transient boolean errorColors;
|
||||||
|
private transient Animator errorTransitionAnimator;
|
||||||
|
public transient View dragLayer;
|
||||||
|
|
||||||
|
public void cancelUpload(){
|
||||||
|
switch(state){
|
||||||
|
case UPLOADING -> {
|
||||||
|
if(uploadRequest!=null){
|
||||||
|
uploadRequest.cancel();
|
||||||
|
uploadRequest=null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
case PROCESSING -> {
|
||||||
|
if(processingPollingRunnable!=null){
|
||||||
|
UiUtils.removeCallbacks(processingPollingRunnable);
|
||||||
|
processingPollingRunnable=null;
|
||||||
|
}
|
||||||
|
if(processingPollingRequest!=null){
|
||||||
|
processingPollingRequest.cancel();
|
||||||
|
processingPollingRequest=null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
default -> throw new IllegalStateException("Unexpected state "+state);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public boolean isUploadingOrProcessing(){
|
||||||
|
return state==AttachmentUploadState.UPLOADING || state==AttachmentUploadState.PROCESSING;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setDescriptionToTitle(){
|
||||||
|
if(TextUtils.isEmpty(description)){
|
||||||
|
titleView.setText(R.string.add_alt_text);
|
||||||
|
titleView.setTextColor(UiUtils.getThemeColor(titleView.getContext(), R.attr.colorM3OnSurfaceVariant));
|
||||||
|
}else{
|
||||||
|
titleView.setText(description);
|
||||||
|
titleView.setTextColor(UiUtils.getThemeColor(titleView.getContext(), R.attr.colorM3OnSurface));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setUseErrorColors(boolean use){
|
||||||
|
if(errorColors==use)
|
||||||
|
return;
|
||||||
|
errorColors=use;
|
||||||
|
if(errorTransitionAnimator!=null)
|
||||||
|
errorTransitionAnimator.cancel();
|
||||||
|
AnimatorSet set=new AnimatorSet();
|
||||||
|
int color1, color2, color3;
|
||||||
|
if(use){
|
||||||
|
color1=UiUtils.getThemeColor(view.getContext(), R.attr.colorM3ErrorContainer);
|
||||||
|
color2=UiUtils.getThemeColor(view.getContext(), R.attr.colorM3Error);
|
||||||
|
color3=UiUtils.getThemeColor(view.getContext(), R.attr.colorM3OnErrorContainer);
|
||||||
|
}else{
|
||||||
|
color1=UiUtils.getThemeColor(view.getContext(), R.attr.colorM3Surface);
|
||||||
|
color2=UiUtils.getThemeColor(view.getContext(), R.attr.colorM3OnSurface);
|
||||||
|
color3=UiUtils.getThemeColor(view.getContext(), R.attr.colorM3OnSurfaceVariant);
|
||||||
|
}
|
||||||
|
set.playTogether(
|
||||||
|
ObjectAnimator.ofArgb(view, "backgroundColor", ((ColorDrawable)view.getBackground()).getColor(), color1),
|
||||||
|
ObjectAnimator.ofArgb(titleView, "textColor", titleView.getCurrentTextColor(), color2),
|
||||||
|
ObjectAnimator.ofArgb(subtitleView, "textColor", subtitleView.getCurrentTextColor(), color3),
|
||||||
|
ObjectAnimator.ofArgb(removeButton.getDrawable(), "tint", subtitleView.getCurrentTextColor(), color3)
|
||||||
|
);
|
||||||
|
editButton.getDrawable().setTint(color3);
|
||||||
|
set.setDuration(250);
|
||||||
|
set.setInterpolator(CubicBezierInterpolator.DEFAULT);
|
||||||
|
set.addListener(new AnimatorListenerAdapter(){
|
||||||
|
@Override
|
||||||
|
public void onAnimationEnd(Animator animation){
|
||||||
|
errorTransitionAnimator=null;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
set.start();
|
||||||
|
errorTransitionAnimator=set;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
enum AttachmentUploadState{
|
||||||
|
QUEUED,
|
||||||
|
UPLOADING,
|
||||||
|
PROCESSING,
|
||||||
|
ERROR,
|
||||||
|
DONE
|
||||||
|
}
|
||||||
|
|
||||||
|
private class AttachmentDragListener implements ReorderableLinearLayout.OnDragListener{
|
||||||
|
private final HashMap<View, Animator> currentAnimations=new HashMap<>();
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void onSwapItems(int oldIndex, int newIndex){
|
||||||
|
attachments.add(newIndex, attachments.remove(oldIndex));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void onDragStart(View view){
|
||||||
|
if(currentAnimations.containsKey(view))
|
||||||
|
currentAnimations.get(view).cancel();
|
||||||
|
fragment.mainLayout.setClipChildren(false);
|
||||||
|
AnimatorSet set=new AnimatorSet();
|
||||||
|
DraftMediaAttachment att=(DraftMediaAttachment) view.getTag();
|
||||||
|
att.dragLayer.setVisibility(View.VISIBLE);
|
||||||
|
set.playTogether(
|
||||||
|
ObjectAnimator.ofFloat(view, View.TRANSLATION_Z, V.dp(3)),
|
||||||
|
ObjectAnimator.ofFloat(att.dragLayer, View.ALPHA, 0.16f)
|
||||||
|
);
|
||||||
|
set.setDuration(150);
|
||||||
|
set.setInterpolator(CubicBezierInterpolator.DEFAULT);
|
||||||
|
set.addListener(new AnimatorListenerAdapter(){
|
||||||
|
@Override
|
||||||
|
public void onAnimationEnd(Animator animation){
|
||||||
|
currentAnimations.remove(view);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
currentAnimations.put(view, set);
|
||||||
|
set.start();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void onDragEnd(View view){
|
||||||
|
if(currentAnimations.containsKey(view))
|
||||||
|
currentAnimations.get(view).cancel();
|
||||||
|
AnimatorSet set=new AnimatorSet();
|
||||||
|
DraftMediaAttachment att=(DraftMediaAttachment) view.getTag();
|
||||||
|
set.playTogether(
|
||||||
|
ObjectAnimator.ofFloat(att.dragLayer, View.ALPHA, 0),
|
||||||
|
ObjectAnimator.ofFloat(view, View.TRANSLATION_Z, 0),
|
||||||
|
ObjectAnimator.ofFloat(view, View.TRANSLATION_X, 0),
|
||||||
|
ObjectAnimator.ofFloat(view, View.TRANSLATION_Y, 0)
|
||||||
|
);
|
||||||
|
set.setDuration(200);
|
||||||
|
set.setInterpolator(CubicBezierInterpolator.DEFAULT);
|
||||||
|
set.addListener(new AnimatorListenerAdapter(){
|
||||||
|
@Override
|
||||||
|
public void onAnimationEnd(Animator animation){
|
||||||
|
if(currentAnimations.isEmpty())
|
||||||
|
fragment.mainLayout.setClipChildren(true);
|
||||||
|
att.dragLayer.setVisibility(View.GONE);
|
||||||
|
currentAnimations.remove(view);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
currentAnimations.put(view, set);
|
||||||
|
set.start();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,426 @@
|
||||||
|
package org.joinmastodon.android.ui.viewcontrollers;
|
||||||
|
|
||||||
|
import android.animation.Animator;
|
||||||
|
import android.animation.AnimatorListenerAdapter;
|
||||||
|
import android.animation.AnimatorSet;
|
||||||
|
import android.animation.ObjectAnimator;
|
||||||
|
import android.app.AlertDialog;
|
||||||
|
import android.graphics.RectF;
|
||||||
|
import android.os.Bundle;
|
||||||
|
import android.view.HapticFeedbackConstants;
|
||||||
|
import android.view.LayoutInflater;
|
||||||
|
import android.view.View;
|
||||||
|
import android.view.ViewGroup;
|
||||||
|
import android.widget.Checkable;
|
||||||
|
import android.widget.EditText;
|
||||||
|
import android.widget.ImageView;
|
||||||
|
import android.widget.TextView;
|
||||||
|
|
||||||
|
import org.joinmastodon.android.R;
|
||||||
|
import org.joinmastodon.android.api.requests.statuses.CreateStatus;
|
||||||
|
import org.joinmastodon.android.fragments.ComposeFragment;
|
||||||
|
import org.joinmastodon.android.model.Instance;
|
||||||
|
import org.joinmastodon.android.model.Poll;
|
||||||
|
import org.joinmastodon.android.ui.M3AlertDialogBuilder;
|
||||||
|
import org.joinmastodon.android.ui.OutlineProviders;
|
||||||
|
import org.joinmastodon.android.ui.drawables.EmptyDrawable;
|
||||||
|
import org.joinmastodon.android.ui.text.LengthLimitHighlighter;
|
||||||
|
import org.joinmastodon.android.ui.utils.SimpleTextWatcher;
|
||||||
|
import org.joinmastodon.android.ui.utils.UiUtils;
|
||||||
|
import org.joinmastodon.android.ui.views.CheckableLinearLayout;
|
||||||
|
import org.joinmastodon.android.ui.views.ReorderableLinearLayout;
|
||||||
|
|
||||||
|
import java.time.temporal.ChronoUnit;
|
||||||
|
import java.util.ArrayList;
|
||||||
|
|
||||||
|
import me.grishka.appkit.utils.CubicBezierInterpolator;
|
||||||
|
import me.grishka.appkit.utils.V;
|
||||||
|
|
||||||
|
public class ComposePollViewController{
|
||||||
|
private static final int[] POLL_LENGTH_OPTIONS={
|
||||||
|
5*60,
|
||||||
|
30*60,
|
||||||
|
3600,
|
||||||
|
6*3600,
|
||||||
|
24*3600,
|
||||||
|
3*24*3600,
|
||||||
|
7*24*3600,
|
||||||
|
};
|
||||||
|
|
||||||
|
private final ComposeFragment fragment;
|
||||||
|
private ViewGroup pollWrap;
|
||||||
|
|
||||||
|
private ReorderableLinearLayout pollOptionsView;
|
||||||
|
private View addPollOptionBtn;
|
||||||
|
private ImageView deletePollOptionBtn;
|
||||||
|
private ViewGroup pollSettingsView;
|
||||||
|
private View pollPoof;
|
||||||
|
private View pollDurationButton, pollStyleButton;
|
||||||
|
private TextView pollDurationValue, pollStyleValue;
|
||||||
|
|
||||||
|
private int pollDuration=24*3600;
|
||||||
|
private boolean pollIsMultipleChoice;
|
||||||
|
private ArrayList<DraftPollOption> pollOptions=new ArrayList<>();
|
||||||
|
private boolean pollChanged;
|
||||||
|
|
||||||
|
private int maxPollOptions=4;
|
||||||
|
private int maxPollOptionLength=50;
|
||||||
|
|
||||||
|
public ComposePollViewController(ComposeFragment fragment){
|
||||||
|
this.fragment=fragment;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setView(View view, Bundle savedInstanceState){
|
||||||
|
pollWrap=view.findViewById(R.id.poll_wrap);
|
||||||
|
|
||||||
|
Instance instance=fragment.instance;
|
||||||
|
if(instance.configuration!=null && instance.configuration.polls!=null && instance.configuration.polls.maxOptions>0)
|
||||||
|
maxPollOptions=instance.configuration.polls.maxOptions;
|
||||||
|
if(instance.configuration!=null && instance.configuration.polls!=null && instance.configuration.polls.maxCharactersPerOption>0)
|
||||||
|
maxPollOptionLength=instance.configuration.polls.maxCharactersPerOption;
|
||||||
|
|
||||||
|
pollOptionsView=pollWrap.findViewById(R.id.poll_options);
|
||||||
|
addPollOptionBtn=pollWrap.findViewById(R.id.add_poll_option);
|
||||||
|
deletePollOptionBtn=pollWrap.findViewById(R.id.delete_poll_option);
|
||||||
|
pollSettingsView=pollWrap.findViewById(R.id.poll_settings);
|
||||||
|
pollPoof=pollWrap.findViewById(R.id.poll_poof);
|
||||||
|
|
||||||
|
addPollOptionBtn.setOnClickListener(v->{
|
||||||
|
createDraftPollOption(true).edit.requestFocus();
|
||||||
|
updatePollOptionHints();
|
||||||
|
});
|
||||||
|
pollOptionsView.setMoveInBothDimensions(true);
|
||||||
|
pollOptionsView.setDragListener(new OptionDragListener());
|
||||||
|
pollOptionsView.setDividerDrawable(new EmptyDrawable(1, V.dp(8)));
|
||||||
|
pollDurationButton=pollWrap.findViewById(R.id.poll_duration);
|
||||||
|
pollDurationValue=pollWrap.findViewById(R.id.poll_duration_value);
|
||||||
|
pollDurationButton.setOnClickListener(v->showPollDurationAlert());
|
||||||
|
pollStyleButton=pollWrap.findViewById(R.id.poll_style);
|
||||||
|
pollStyleValue=pollWrap.findViewById(R.id.poll_style_value);
|
||||||
|
pollStyleButton.setOnClickListener(v->showPollStyleAlert());
|
||||||
|
|
||||||
|
if(!fragment.getWasDetached() && savedInstanceState!=null && savedInstanceState.containsKey("pollOptions")){ // Fragment was recreated without retaining instance
|
||||||
|
pollWrap.setVisibility(View.VISIBLE);
|
||||||
|
for(String oldText:savedInstanceState.getStringArrayList("pollOptions")){
|
||||||
|
DraftPollOption opt=createDraftPollOption(false);
|
||||||
|
opt.edit.setText(oldText);
|
||||||
|
}
|
||||||
|
updatePollOptionHints();
|
||||||
|
pollDuration=savedInstanceState.getInt("pollDuration");
|
||||||
|
pollIsMultipleChoice=savedInstanceState.getBoolean("pollMultiple");
|
||||||
|
pollDurationValue.setText(formatPollDuration(pollDuration));
|
||||||
|
pollStyleValue.setText(pollIsMultipleChoice ? R.string.compose_poll_multiple_choice : R.string.compose_poll_single_choice);
|
||||||
|
}else if(savedInstanceState!=null && !pollOptions.isEmpty()){ // Fragment was recreated but instance was retained
|
||||||
|
pollWrap.setVisibility(View.VISIBLE);
|
||||||
|
ArrayList<DraftPollOption> oldOptions=new ArrayList<>(pollOptions);
|
||||||
|
pollOptions.clear();
|
||||||
|
for(DraftPollOption oldOpt:oldOptions){
|
||||||
|
DraftPollOption opt=createDraftPollOption(false);
|
||||||
|
opt.edit.setText(oldOpt.edit.getText());
|
||||||
|
}
|
||||||
|
updatePollOptionHints();
|
||||||
|
pollDurationValue.setText(formatPollDuration(pollDuration));
|
||||||
|
pollStyleValue.setText(pollIsMultipleChoice ? R.string.compose_poll_multiple_choice : R.string.compose_poll_single_choice);
|
||||||
|
}else if(savedInstanceState==null && fragment.editingStatus!=null && fragment.editingStatus.poll!=null){
|
||||||
|
pollWrap.setVisibility(View.VISIBLE);
|
||||||
|
for(Poll.Option eopt:fragment.editingStatus.poll.options){
|
||||||
|
DraftPollOption opt=createDraftPollOption(false);
|
||||||
|
opt.edit.setText(eopt.title);
|
||||||
|
}
|
||||||
|
pollDuration=(int)fragment.editingStatus.poll.expiresAt.minus(fragment.editingStatus.createdAt.toEpochMilli(), ChronoUnit.MILLIS).getEpochSecond();
|
||||||
|
updatePollOptionHints();
|
||||||
|
pollDurationValue.setText(formatPollDuration(pollDuration));
|
||||||
|
pollIsMultipleChoice=fragment.editingStatus.poll.multiple;
|
||||||
|
pollStyleValue.setText(pollIsMultipleChoice ? R.string.compose_poll_multiple_choice : R.string.compose_poll_single_choice);
|
||||||
|
}else{
|
||||||
|
pollDurationValue.setText(formatPollDuration(24*3600));
|
||||||
|
pollStyleValue.setText(R.string.compose_poll_single_choice);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private DraftPollOption createDraftPollOption(boolean animated){
|
||||||
|
DraftPollOption option=new DraftPollOption();
|
||||||
|
option.view=LayoutInflater.from(fragment.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->{
|
||||||
|
if(!fragment.isCreatingView())
|
||||||
|
pollChanged=true;
|
||||||
|
fragment.updatePublishButtonState();
|
||||||
|
}));
|
||||||
|
option.view.setOutlineProvider(OutlineProviders.roundedRect(4));
|
||||||
|
option.view.setClipToOutline(true);
|
||||||
|
option.view.setTag(option);
|
||||||
|
|
||||||
|
if(animated)
|
||||||
|
UiUtils.beginLayoutTransition(pollWrap);
|
||||||
|
pollOptionsView.addView(option.view);
|
||||||
|
pollOptions.add(option);
|
||||||
|
addPollOptionBtn.setEnabled(pollOptions.size()<maxPollOptions);
|
||||||
|
option.edit.addTextChangedListener(new LengthLimitHighlighter(fragment.getActivity(), maxPollOptionLength).setListener(isOverLimit->{
|
||||||
|
option.view.setForeground(fragment.getResources().getDrawable(isOverLimit ? R.drawable.bg_m3_outlined_text_field_error_nopad : R.drawable.bg_m3_outlined_text_field_nopad, fragment.getActivity().getTheme()));
|
||||||
|
}));
|
||||||
|
return option;
|
||||||
|
}
|
||||||
|
|
||||||
|
private void updatePollOptionHints(){
|
||||||
|
int i=0;
|
||||||
|
for(DraftPollOption option:pollOptions){
|
||||||
|
option.edit.setHint(fragment.getString(R.string.poll_option_hint, ++i));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void onSwapPollOptions(int oldIndex, int newIndex){
|
||||||
|
pollOptions.add(newIndex, pollOptions.remove(oldIndex));
|
||||||
|
updatePollOptionHints();
|
||||||
|
pollChanged=true;
|
||||||
|
}
|
||||||
|
|
||||||
|
private void showPollDurationAlert(){
|
||||||
|
String[] options=new String[POLL_LENGTH_OPTIONS.length];
|
||||||
|
int selectedOption=-1;
|
||||||
|
for(int i=0;i<POLL_LENGTH_OPTIONS.length;i++){
|
||||||
|
int l=POLL_LENGTH_OPTIONS[i];
|
||||||
|
options[i]=formatPollDuration(l);
|
||||||
|
if(l==pollDuration)
|
||||||
|
selectedOption=i;
|
||||||
|
}
|
||||||
|
int[] chosenOption={0};
|
||||||
|
new M3AlertDialogBuilder(fragment.getActivity())
|
||||||
|
.setSingleChoiceItems(options, selectedOption, (dialog, which)->chosenOption[0]=which)
|
||||||
|
.setTitle(R.string.poll_length)
|
||||||
|
.setPositiveButton(R.string.ok, (dialog, which)->{
|
||||||
|
pollDuration=POLL_LENGTH_OPTIONS[chosenOption[0]];
|
||||||
|
pollDurationValue.setText(formatPollDuration(pollDuration));
|
||||||
|
})
|
||||||
|
.setNegativeButton(R.string.cancel, null)
|
||||||
|
.show();
|
||||||
|
}
|
||||||
|
|
||||||
|
private String formatPollDuration(int seconds){
|
||||||
|
if(seconds<3600){
|
||||||
|
int minutes=seconds/60;
|
||||||
|
return fragment.getResources().getQuantityString(R.plurals.x_minutes, minutes, minutes);
|
||||||
|
}else if(seconds<24*3600){
|
||||||
|
int hours=seconds/3600;
|
||||||
|
return fragment.getResources().getQuantityString(R.plurals.x_hours, hours, hours);
|
||||||
|
}else{
|
||||||
|
int days=seconds/(24*3600);
|
||||||
|
return fragment.getResources().getQuantityString(R.plurals.x_days, days, days);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void showPollStyleAlert(){
|
||||||
|
final int[] option={pollIsMultipleChoice ? R.id.multiple_choice : R.id.single_choice};
|
||||||
|
AlertDialog alert=new M3AlertDialogBuilder(fragment.getActivity())
|
||||||
|
.setView(R.layout.poll_style)
|
||||||
|
.setTitle(R.string.poll_style_title)
|
||||||
|
.setPositiveButton(R.string.ok, (dlg, which)->{
|
||||||
|
pollIsMultipleChoice=option[0]==R.id.multiple_choice;
|
||||||
|
pollStyleValue.setText(pollIsMultipleChoice ? R.string.compose_poll_multiple_choice : R.string.compose_poll_single_choice);
|
||||||
|
})
|
||||||
|
.setNegativeButton(R.string.cancel, null)
|
||||||
|
.show();
|
||||||
|
CheckableLinearLayout multiple=alert.findViewById(R.id.multiple_choice);
|
||||||
|
CheckableLinearLayout single=alert.findViewById(R.id.single_choice);
|
||||||
|
single.setChecked(!pollIsMultipleChoice);
|
||||||
|
multiple.setChecked(pollIsMultipleChoice);
|
||||||
|
View.OnClickListener listener=v->{
|
||||||
|
int id=v.getId();
|
||||||
|
if(id==option[0])
|
||||||
|
return;
|
||||||
|
((Checkable) alert.findViewById(option[0])).setChecked(false);
|
||||||
|
((Checkable) v).setChecked(true);
|
||||||
|
option[0]=id;
|
||||||
|
};
|
||||||
|
single.setOnClickListener(listener);
|
||||||
|
multiple.setOnClickListener(listener);
|
||||||
|
}
|
||||||
|
|
||||||
|
public void onSaveInstanceState(Bundle outState){
|
||||||
|
if(!pollOptions.isEmpty()){
|
||||||
|
ArrayList<String> opts=new ArrayList<>();
|
||||||
|
for(DraftPollOption opt:pollOptions){
|
||||||
|
opts.add(opt.edit.getText().toString());
|
||||||
|
}
|
||||||
|
outState.putStringArrayList("pollOptions", opts);
|
||||||
|
outState.putInt("pollDuration", pollDuration);
|
||||||
|
outState.putBoolean("pollMultiple", pollIsMultipleChoice);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public boolean isEmpty(){
|
||||||
|
return pollOptions.isEmpty();
|
||||||
|
}
|
||||||
|
|
||||||
|
public int getNonEmptyOptionsCount(){
|
||||||
|
int nonEmptyPollOptionsCount=0;
|
||||||
|
for(DraftPollOption opt:pollOptions){
|
||||||
|
if(opt.edit.length()>0)
|
||||||
|
nonEmptyPollOptionsCount++;
|
||||||
|
}
|
||||||
|
return nonEmptyPollOptionsCount;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void toggle(){
|
||||||
|
if(pollOptions.isEmpty()){
|
||||||
|
pollWrap.setVisibility(View.VISIBLE);
|
||||||
|
for(int i=0;i<2;i++)
|
||||||
|
createDraftPollOption(false);
|
||||||
|
updatePollOptionHints();
|
||||||
|
}else{
|
||||||
|
pollWrap.setVisibility(View.GONE);
|
||||||
|
addPollOptionBtn.setVisibility(View.VISIBLE);
|
||||||
|
pollOptionsView.removeAllViews();
|
||||||
|
pollOptions.clear();
|
||||||
|
pollDuration=24*3600;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public boolean isShown(){
|
||||||
|
return !pollOptions.isEmpty();
|
||||||
|
}
|
||||||
|
|
||||||
|
public boolean isPollChanged(){
|
||||||
|
return pollChanged;
|
||||||
|
}
|
||||||
|
|
||||||
|
public CreateStatus.Request.Poll getPollForRequest(){
|
||||||
|
CreateStatus.Request.Poll poll=new CreateStatus.Request.Poll();
|
||||||
|
poll.expiresIn=pollDuration;
|
||||||
|
poll.multiple=pollIsMultipleChoice;
|
||||||
|
for(DraftPollOption opt:pollOptions)
|
||||||
|
poll.options.add(opt.edit.getText().toString());
|
||||||
|
return poll;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static class DraftPollOption{
|
||||||
|
public EditText edit;
|
||||||
|
public View view;
|
||||||
|
public View dragger;
|
||||||
|
}
|
||||||
|
|
||||||
|
private class OptionDragListener implements ReorderableLinearLayout.OnDragListener{
|
||||||
|
private boolean isOverDelete;
|
||||||
|
private RectF rect1, rect2;
|
||||||
|
private Animator deletionStateAnimator;
|
||||||
|
|
||||||
|
public OptionDragListener(){
|
||||||
|
rect1=new RectF();
|
||||||
|
rect2=new RectF();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void onSwapItems(int oldIndex, int newIndex){
|
||||||
|
onSwapPollOptions(oldIndex, newIndex);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void onDragStart(View view){
|
||||||
|
isOverDelete=false;
|
||||||
|
ReorderableLinearLayout.OnDragListener.super.onDragStart(view);
|
||||||
|
DraftPollOption dpo=(DraftPollOption) view.getTag();
|
||||||
|
int color=UiUtils.getThemeColor(fragment.getActivity(), R.attr.colorM3OnSurface);
|
||||||
|
ObjectAnimator anim=ObjectAnimator.ofArgb(dpo.edit, "backgroundColor", color & 0xffffff, color & 0x29ffffff);
|
||||||
|
anim.setDuration(150);
|
||||||
|
anim.setInterpolator(CubicBezierInterpolator.DEFAULT);
|
||||||
|
anim.start();
|
||||||
|
fragment.mainLayout.setClipChildren(false);
|
||||||
|
if(pollOptions.size()>2){
|
||||||
|
// UiUtils.beginLayoutTransition(pollSettingsView);
|
||||||
|
deletePollOptionBtn.setVisibility(View.VISIBLE);
|
||||||
|
addPollOptionBtn.setVisibility(View.GONE);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void onDragEnd(View view){
|
||||||
|
if(pollOptions.size()>2){
|
||||||
|
// UiUtils.beginLayoutTransition(pollSettingsView);
|
||||||
|
deletePollOptionBtn.setVisibility(View.GONE);
|
||||||
|
addPollOptionBtn.setVisibility(View.VISIBLE);
|
||||||
|
}
|
||||||
|
|
||||||
|
DraftPollOption dpo=(DraftPollOption) view.getTag();
|
||||||
|
if(isOverDelete){
|
||||||
|
pollPoof.setVisibility(View.VISIBLE);
|
||||||
|
AnimatorSet set=new AnimatorSet();
|
||||||
|
set.playTogether(
|
||||||
|
ObjectAnimator.ofFloat(pollPoof, View.ALPHA, 0f, 0.7f, 1f, 0f),
|
||||||
|
ObjectAnimator.ofFloat(pollPoof, View.SCALE_X, 1f, 4f),
|
||||||
|
ObjectAnimator.ofFloat(pollPoof, View.SCALE_Y, 1f, 4f),
|
||||||
|
ObjectAnimator.ofFloat(pollPoof, View.ROTATION, 0f, 60f)
|
||||||
|
);
|
||||||
|
set.setDuration(600);
|
||||||
|
set.setInterpolator(CubicBezierInterpolator.DEFAULT);
|
||||||
|
set.addListener(new AnimatorListenerAdapter(){
|
||||||
|
@Override
|
||||||
|
public void onAnimationEnd(Animator animation){
|
||||||
|
pollPoof.setVisibility(View.INVISIBLE);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
set.start();
|
||||||
|
UiUtils.beginLayoutTransition(pollWrap);
|
||||||
|
pollOptions.remove(dpo);
|
||||||
|
pollOptionsView.removeView(view);
|
||||||
|
addPollOptionBtn.setEnabled(pollOptions.size()<maxPollOptions);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
ReorderableLinearLayout.OnDragListener.super.onDragEnd(view);
|
||||||
|
int color=UiUtils.getThemeColor(fragment.getActivity(), R.attr.colorM3OnSurface);
|
||||||
|
ObjectAnimator anim=ObjectAnimator.ofArgb(dpo.edit, "backgroundColor", color & 0x29ffffff, color & 0xffffff);
|
||||||
|
anim.setDuration(200);
|
||||||
|
anim.setInterpolator(CubicBezierInterpolator.DEFAULT);
|
||||||
|
anim.addListener(new AnimatorListenerAdapter(){
|
||||||
|
@Override
|
||||||
|
public void onAnimationEnd(Animator animation){
|
||||||
|
fragment.mainLayout.setClipChildren(true);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
anim.start();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void onDragMove(View view){
|
||||||
|
if(pollOptions.size()<3)
|
||||||
|
return;
|
||||||
|
DraftPollOption dpo=(DraftPollOption) view.getTag();
|
||||||
|
// Yes, I don't like this either.
|
||||||
|
float draggerX=view.getX()+dpo.dragger.getX()+pollOptionsView.getX();
|
||||||
|
float deleteButtonX=pollSettingsView.getX()+deletePollOptionBtn.getX();
|
||||||
|
rect1.set(deleteButtonX, pollOptionsView.getHeight(), deleteButtonX+deletePollOptionBtn.getWidth(), pollWrap.getHeight());
|
||||||
|
rect2.set(draggerX, view.getY(), draggerX+dpo.dragger.getWidth(), view.getY()+view.getHeight());
|
||||||
|
boolean newOverDelete=rect1.intersect(rect2);
|
||||||
|
if(newOverDelete!=isOverDelete){
|
||||||
|
if(deletionStateAnimator!=null)
|
||||||
|
deletionStateAnimator.cancel();
|
||||||
|
isOverDelete=newOverDelete;
|
||||||
|
if(newOverDelete)
|
||||||
|
view.performHapticFeedback(HapticFeedbackConstants.KEYBOARD_TAP);
|
||||||
|
dpo.view.setForeground(fragment.getResources().getDrawable(newOverDelete || dpo.edit.length()>maxPollOptionLength ? R.drawable.bg_m3_outlined_text_field_error_nopad : R.drawable.bg_m3_outlined_text_field_nopad, fragment.getActivity().getTheme()));
|
||||||
|
int errorContainer=UiUtils.getThemeColor(fragment.getActivity(), R.attr.colorM3ErrorContainer);
|
||||||
|
int surface=UiUtils.getThemeColor(fragment.getActivity(), R.attr.colorM3Surface);
|
||||||
|
AnimatorSet set=new AnimatorSet();
|
||||||
|
set.playTogether(
|
||||||
|
ObjectAnimator.ofFloat(view, View.ALPHA, newOverDelete ? .85f : 1),
|
||||||
|
ObjectAnimator.ofArgb(view, "backgroundColor", newOverDelete ? surface : errorContainer, newOverDelete ? errorContainer : surface)
|
||||||
|
);
|
||||||
|
set.setDuration(150);
|
||||||
|
set.setInterpolator(CubicBezierInterpolator.DEFAULT);
|
||||||
|
deletionStateAnimator=set;
|
||||||
|
set.addListener(new AnimatorListenerAdapter(){
|
||||||
|
@Override
|
||||||
|
public void onAnimationEnd(Animator animation){
|
||||||
|
deletionStateAnimator=null;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
set.start();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,4 @@
|
||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<menu xmlns:android="http://schemas.android.com/apk/res/android">
|
||||||
|
<item android:id="@+id/publish" android:icon="@drawable/ic_save_24px" android:showAsAction="always" android:title="@string/save"/>
|
||||||
|
</menu>
|
|
@ -462,6 +462,11 @@
|
||||||
<string name="attachment_description_video">%s video</string>
|
<string name="attachment_description_video">%s video</string>
|
||||||
<string name="attachment_description_audio">%s audio</string>
|
<string name="attachment_description_audio">%s audio</string>
|
||||||
<string name="attachment_description_unknown">%s file</string>
|
<string name="attachment_description_unknown">%s file</string>
|
||||||
|
<string name="attachment_type_image">Image</string>
|
||||||
|
<string name="attachment_type_video">Video</string>
|
||||||
|
<string name="attachment_type_audio">Audio</string>
|
||||||
|
<string name="attachment_type_gif">GIF</string>
|
||||||
|
<string name="attachment_type_unknown">File</string>
|
||||||
<string name="attachment_x_percent_uploaded">%d%% uploaded</string>
|
<string name="attachment_x_percent_uploaded">%d%% uploaded</string>
|
||||||
<string name="add_poll_option">Add poll option</string>
|
<string name="add_poll_option">Add poll option</string>
|
||||||
<string name="poll_length">Poll length</string>
|
<string name="poll_length">Poll length</string>
|
||||||
|
@ -474,4 +479,5 @@
|
||||||
<string name="help">Help</string>
|
<string name="help">Help</string>
|
||||||
<string name="what_is_alt_text">What is alt text?</string>
|
<string name="what_is_alt_text">What is alt text?</string>
|
||||||
<string name="alt_text_help">Alt text provides image descriptions for people with vision impairments, low-bandwidth connections, or those seeking extra context.\n\nYou can improve accessibility and understanding for everyone by writing clear, concise, and objective alt text.\n\n<ul><li>Capture important elements</li>\n<li>Summarize text in images</li>\n<li>Use regular sentence structure</li>\n<li>Avoid redundant information</li>\n<li>Focus on trends and key findings in complex visuals (like diagrams or maps)</li></ul></string>
|
<string name="alt_text_help">Alt text provides image descriptions for people with vision impairments, low-bandwidth connections, or those seeking extra context.\n\nYou can improve accessibility and understanding for everyone by writing clear, concise, and objective alt text.\n\n<ul><li>Capture important elements</li>\n<li>Summarize text in images</li>\n<li>Use regular sentence structure</li>\n<li>Avoid redundant information</li>\n<li>Focus on trends and key findings in complex visuals (like diagrams or maps)</li></ul></string>
|
||||||
|
<string name="edit_post">Edit post</string>
|
||||||
</resources>
|
</resources>
|
Loading…
Reference in New Issue