diff --git a/mastodon/src/main/java/org/joinmastodon/android/fragments/ComposeFragment.java b/mastodon/src/main/java/org/joinmastodon/android/fragments/ComposeFragment.java index ffb7ee49..0cbf0714 100644 --- a/mastodon/src/main/java/org/joinmastodon/android/fragments/ComposeFragment.java +++ b/mastodon/src/main/java/org/joinmastodon/android/fragments/ComposeFragment.java @@ -1,32 +1,18 @@ package org.joinmastodon.android.fragments; -import android.animation.Animator; -import android.animation.AnimatorListenerAdapter; -import android.animation.AnimatorSet; -import android.animation.ObjectAnimator; import android.annotation.SuppressLint; import android.app.Activity; -import android.app.AlertDialog; import android.content.ClipData; -import android.content.Context; import android.content.Intent; import android.content.res.Configuration; -import android.database.Cursor; -import android.graphics.Bitmap; import android.graphics.Outline; import android.graphics.PixelFormat; -import android.graphics.RectF; -import android.graphics.drawable.ColorDrawable; import android.graphics.drawable.Drawable; import android.graphics.drawable.LayerDrawable; import android.icu.text.BreakIterator; -import android.media.MediaMetadataRetriever; import android.net.Uri; -import android.os.Build; import android.os.Bundle; -import android.os.Parcelable; import android.provider.MediaStore; -import android.provider.OpenableColumns; import android.text.Editable; import android.text.Spanned; import android.text.TextUtils; @@ -34,7 +20,6 @@ import android.text.TextWatcher; import android.text.style.BackgroundColorSpan; import android.text.style.ForegroundColorSpan; import android.util.Log; -import android.view.HapticFeedbackConstants; import android.view.LayoutInflater; import android.view.Menu; import android.view.MenuInflater; @@ -45,74 +30,55 @@ import android.view.ViewOutlineProvider; import android.view.WindowManager; import android.view.inputmethod.InputMethodManager; import android.widget.Button; -import android.widget.Checkable; import android.widget.EditText; import android.widget.FrameLayout; -import android.widget.HorizontalScrollView; import android.widget.ImageButton; import android.widget.ImageView; import android.widget.LinearLayout; import android.widget.PopupMenu; import android.widget.ProgressBar; import android.widget.TextView; -import android.widget.Toast; import com.twitter.twittertext.TwitterTextEmojiRegex; import org.joinmastodon.android.E; -import org.joinmastodon.android.MastodonApp; import org.joinmastodon.android.R; -import org.joinmastodon.android.api.MastodonAPIController; import org.joinmastodon.android.api.MastodonErrorResponse; -import org.joinmastodon.android.api.ProgressListener; import org.joinmastodon.android.api.requests.accounts.GetPreferences; import org.joinmastodon.android.api.requests.statuses.CreateStatus; import org.joinmastodon.android.api.requests.statuses.EditStatus; -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.api.session.AccountSession; import org.joinmastodon.android.api.session.AccountSessionManager; import org.joinmastodon.android.events.StatusCountersUpdatedEvent; import org.joinmastodon.android.events.StatusCreatedEvent; import org.joinmastodon.android.events.StatusUpdatedEvent; import org.joinmastodon.android.model.Account; -import org.joinmastodon.android.model.Attachment; import org.joinmastodon.android.model.Emoji; import org.joinmastodon.android.model.EmojiCategory; import org.joinmastodon.android.model.Instance; import org.joinmastodon.android.model.Mention; -import org.joinmastodon.android.model.Poll; import org.joinmastodon.android.model.Preferences; import org.joinmastodon.android.model.Status; import org.joinmastodon.android.model.StatusPrivacy; -import org.joinmastodon.android.ui.ComposeAutocompleteViewController; import org.joinmastodon.android.ui.CustomEmojiPopupKeyboard; import org.joinmastodon.android.ui.M3AlertDialogBuilder; import org.joinmastodon.android.ui.OutlineProviders; import org.joinmastodon.android.ui.PopupKeyboard; -import org.joinmastodon.android.ui.drawables.EmptyDrawable; import org.joinmastodon.android.ui.drawables.SpoilerStripesDrawable; import org.joinmastodon.android.ui.text.ComposeAutocompleteSpan; import org.joinmastodon.android.ui.text.ComposeHashtagOrMentionSpan; import org.joinmastodon.android.ui.text.HtmlParser; -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.viewcontrollers.ComposeAutocompleteViewController; +import org.joinmastodon.android.ui.viewcontrollers.ComposeMediaViewController; +import org.joinmastodon.android.ui.viewcontrollers.ComposePollViewController; import org.joinmastodon.android.ui.views.ComposeEditText; -import org.joinmastodon.android.ui.views.ReorderableLinearLayout; import org.joinmastodon.android.ui.views.SizeListenerLinearLayout; -import org.joinmastodon.android.utils.TransferSpeedTracker; -import org.parceler.Parcel; import org.parceler.Parcels; -import java.time.temporal.ChronoUnit; import java.util.ArrayList; -import java.util.HashMap; import java.util.List; -import java.util.Locale; -import java.util.Objects; import java.util.UUID; import java.util.regex.Matcher; import java.util.regex.Pattern; @@ -124,24 +90,13 @@ import me.grishka.appkit.api.ErrorResponse; import me.grishka.appkit.fragments.OnBackPressedListener; 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 ComposeFragment extends MastodonToolbarFragment implements OnBackPressedListener, ComposeEditText.SelectionListener{ private static final int MEDIA_RESULT=717; - private static final int IMAGE_DESCRIPTION_RESULT=363; - private static final int MAX_ATTACHMENTS=4; + public static final int IMAGE_DESCRIPTION_RESULT=363; private static final String TAG="ComposeFragment"; - private static final int[] POLL_LENGTH_OPTIONS={ - 5*60, - 30*60, - 3600, - 6*3600, - 24*3600, - 3*24*3600, - 7*24*3600, - }; private static final Pattern MENTION_PATTERN=Pattern.compile("(^|[^\\/\\w])@(([a-z0-9_]+)@[a-z0-9\\.\\-]+[a-z0-9]+)", Pattern.CASE_INSENSITIVE); @@ -152,7 +107,7 @@ public class ComposeFragment extends MastodonToolbarFragment implements OnBackPr @SuppressLint("NewApi") // this class actually exists on 6.0 private final BreakIterator breakIterator=BreakIterator.getCharacterInstance(); - private LinearLayout mainLayout; + public LinearLayout mainLayout; private SizeListenerLinearLayout contentView; private TextView selfName, selfUsername; private ImageView selfAvatar; @@ -165,33 +120,16 @@ public class ComposeFragment extends MastodonToolbarFragment implements OnBackPr private int charCount, charLimit, trimmedCharCount; private ImageButton mediaBtn, pollBtn, emojiBtn, spoilerBtn; - private ReorderableLinearLayout attachmentsView; - private HorizontalScrollView attachmentsScroller; private TextView replyText; - private ReorderableLinearLayout pollOptionsView; - private ViewGroup pollWrap; - private View addPollOptionBtn; private Button visibilityBtn; private LinearLayout bottomBar; - private ImageView deletePollOptionBtn; - private ViewGroup pollSettingsView; - private View pollPoof; private View autocompleteDivider; - private View pollDurationButton, pollStyleButton; - private TextView pollDurationValue, pollStyleValue; - - private ArrayList pollOptions=new ArrayList<>(); - - private ArrayList attachments=new ArrayList<>(); - private List customEmojis; private CustomEmojiPopupKeyboard emojiKeyboard; private Status replyTo; private String initialText; private String uuid; - private int pollDuration=24*3600; - private boolean pollIsMultipleChoice; private EditText spoilerEdit; private View spoilerWrap; private boolean hasSpoiler; @@ -201,12 +139,13 @@ public class ComposeFragment extends MastodonToolbarFragment implements OnBackPr private StatusPrivacy statusVisibility=StatusPrivacy.PUBLIC; private ComposeAutocompleteSpan currentAutocompleteSpan; private FrameLayout mainEditTextWrap; - private ComposeAutocompleteViewController autocompleteViewController; - private Instance instance; - private boolean attachmentsErrorShowing; - private Status editingStatus; - private boolean pollChanged; + private ComposeAutocompleteViewController autocompleteViewController; + private ComposePollViewController pollViewController=new ComposePollViewController(this); + private ComposeMediaViewController mediaViewController=new ComposeMediaViewController(this); + public Instance instance; + + public Status editingStatus; private boolean creatingView; private boolean ignoreSelectionChanges=false; private MenuItem publishButton; @@ -215,9 +154,6 @@ public class ComposeFragment extends MastodonToolbarFragment implements OnBackPr private BackgroundColorSpan overLimitBG; private ForegroundColorSpan overLimitFG; - private int maxPollOptions=4; - private int maxPollOptionLength=50; - public ComposeFragment(){ super(R.layout.toolbar_fragment_with_progressbar); } @@ -251,28 +187,21 @@ public class ComposeFragment extends MastodonToolbarFragment implements OnBackPr else charLimit=500; - 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; - - if (editingStatus == null) loadDefaultStatusVisibility(savedInstanceState); + if(editingStatus==null) + loadDefaultStatusVisibility(savedInstanceState); + setTitle(editingStatus==null ? R.string.new_post : R.string.edit_post); } @Override public void onDestroy(){ super.onDestroy(); - for(DraftMediaAttachment att:attachments){ - if(att.isUploadingOrProcessing()) - att.cancelUpload(); - } + mediaViewController.cancelAllUploads(); } @Override public void onAttach(Activity activity){ super.onAttach(activity); setHasOptionsMenu(true); - setTitle(R.string.new_post); wm=activity.getSystemService(WindowManager.class); overLimitBG=new BackgroundColorSpan(UiUtils.getThemeColor(activity, R.attr.colorM3ErrorContainer)); @@ -340,232 +269,6 @@ public class ComposeFragment extends MastodonToolbarFragment implements OnBackPr contentView.addView(emojiKeyboard.getView()); emojiKeyboard.getView().setElevation(V.dp(2)); - attachmentsView=view.findViewById(R.id.attachments); - attachmentsScroller=view.findViewById(R.id.attachments_scroller); - pollOptionsView=view.findViewById(R.id.poll_options); - pollWrap=view.findViewById(R.id.poll_wrap); - addPollOptionBtn=view.findViewById(R.id.add_poll_option); - deletePollOptionBtn=view.findViewById(R.id.delete_poll_option); - pollSettingsView=view.findViewById(R.id.poll_settings); - pollPoof=view.findViewById(R.id.poll_poof); - attachmentsView.setDividerDrawable(new EmptyDrawable(V.dp(8), 0)); - attachmentsView.setDragListener(new ReorderableLinearLayout.OnDragListener(){ - private final HashMap 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(); - 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()) - mainLayout.setClipChildren(true); - att.dragLayer.setVisibility(View.GONE); - currentAnimations.remove(view); - } - }); - currentAnimations.put(view, set); - set.start(); - } - }); - attachmentsView.setMoveInBothDimensions(true); - - addPollOptionBtn.setOnClickListener(v->{ - createDraftPollOption().edit.requestFocus(); - updatePollOptionHints(); - }); - pollOptionsView.setMoveInBothDimensions(true); - pollOptionsView.setDragListener(new ReorderableLinearLayout.OnDragListener(){ - private boolean isOverDelete; - private RectF rect1=new RectF(), rect2=new RectF(); - private Animator deletionStateAnimator; - - @Override - public void onSwapItems(int oldIndex, int newIndex){ - ComposeFragment.this.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(getActivity(), R.attr.colorM3OnSurface); - ObjectAnimator anim=ObjectAnimator.ofArgb(dpo.edit, "backgroundColor", color & 0xffffff, color & 0x29ffffff); - anim.setDuration(150); - anim.setInterpolator(CubicBezierInterpolator.DEFAULT); - anim.start(); - 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()maxPollOptionLength ? R.drawable.bg_m3_outlined_text_field_error_nopad : R.drawable.bg_m3_outlined_text_field_nopad, getActivity().getTheme())); - int errorContainer=UiUtils.getThemeColor(getActivity(), R.attr.colorM3ErrorContainer); - int surface=UiUtils.getThemeColor(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(); - } - } - }); - pollOptionsView.setDividerDrawable(new EmptyDrawable(1, V.dp(8))); - pollDurationButton=view.findViewById(R.id.poll_duration); - pollDurationValue=view.findViewById(R.id.poll_duration_value); - pollDurationButton.setOnClickListener(v->showPollDurationAlert()); - pollStyleButton=view.findViewById(R.id.poll_style); - pollStyleValue=view.findViewById(R.id.poll_style_value); - pollStyleButton.setOnClickListener(v->showPollStyleAlert()); - - pollOptions.clear(); - if(!wasDetached && savedInstanceState!=null && savedInstanceState.containsKey("pollOptions")){ - pollBtn.setSelected(true); - mediaBtn.setEnabled(false); - pollWrap.setVisibility(View.VISIBLE); - for(String oldText:savedInstanceState.getStringArrayList("pollOptions")){ - DraftPollOption opt=createDraftPollOption(); - opt.edit.setText(oldText); - } - updatePollOptionHints(); - pollDurationValue.setText(formatPollDuration(pollDuration)); - pollStyleValue.setText(pollIsMultipleChoice ? R.string.compose_poll_multiple_choice : R.string.compose_poll_single_choice); - }else if(wasDetached && savedInstanceState==null && editingStatus!=null && editingStatus.poll!=null){ - pollBtn.setSelected(true); - mediaBtn.setEnabled(false); - pollWrap.setVisibility(View.VISIBLE); - for(Poll.Option eopt:editingStatus.poll.options){ - DraftPollOption opt=createDraftPollOption(); - opt.edit.setText(eopt.title); - } - pollDuration=(int)editingStatus.poll.expiresAt.minus(editingStatus.createdAt.toEpochMilli(), ChronoUnit.MILLIS).getEpochSecond(); - updatePollOptionHints(); - pollDurationValue.setText(formatPollDuration(pollDuration)); - pollIsMultipleChoice=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); - } - spoilerEdit=view.findViewById(R.id.content_warning); spoilerWrap=view.findViewById(R.id.content_warning_wrap); LayerDrawable spoilerBg=(LayerDrawable) spoilerWrap.getBackground().mutate(); @@ -585,23 +288,6 @@ public class ComposeFragment extends MastodonToolbarFragment implements OnBackPr spoilerBtn.setSelected(true); } - if(!wasDetached && savedInstanceState!=null && savedInstanceState.containsKey("attachments")){ - ArrayList 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(); - } - if(editingStatus!=null && editingStatus.visibility!=null) { statusVisibility=editingStatus.visibility; } @@ -614,6 +300,9 @@ public class ComposeFragment extends MastodonToolbarFragment implements OnBackPr bottomBar.addView(autocompleteView, 0, new LinearLayout.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, V.dp(56))); autocompleteDivider=view.findViewById(R.id.bottom_bar_autocomplete_divider); + pollViewController.setView(view, savedInstanceState); + mediaViewController.setView(view, savedInstanceState); + creatingView=false; return view; @@ -622,22 +311,9 @@ public class ComposeFragment extends MastodonToolbarFragment implements OnBackPr @Override public void onSaveInstanceState(Bundle outState){ super.onSaveInstanceState(outState); - if(!pollOptions.isEmpty()){ - ArrayList opts=new ArrayList<>(); - for(DraftPollOption opt:pollOptions){ - opts.add(opt.edit.getText().toString()); - } - outState.putStringArrayList("pollOptions", opts); - outState.putInt("pollDuration", pollDuration); - } + pollViewController.onSaveInstanceState(outState); + mediaViewController.onSaveInstanceState(outState); outState.putBoolean("hasSpoiler", hasSpoiler); - if(!attachments.isEmpty()){ - ArrayList serializedAttachments=new ArrayList<>(attachments.size()); - for(DraftMediaAttachment att:attachments){ - serializedAttachments.add(Parcels.wrap(att)); - } - outState.putParcelableArrayList("attachments", serializedAttachments); - } outState.putSerializable("visibility", statusVisibility); } @@ -769,20 +445,7 @@ public class ComposeFragment extends MastodonToolbarFragment implements OnBackPr ignoreSelectionChanges=true; mainEditText.setSelection(mainEditText.length()); ignoreSelectionChanges=false; - if(!editingStatus.mediaAttachments.isEmpty()){ - attachmentsScroller.setVisibility(View.VISIBLE); - for(Attachment att: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(); - pollBtn.setEnabled(false); - } + mediaViewController.onViewCreated(savedInstanceState);; }else{ String prefilledText=getArguments().getString("prefilledText"); if(!TextUtils.isEmpty(prefilledText)){ @@ -795,7 +458,7 @@ public class ComposeFragment extends MastodonToolbarFragment implements OnBackPr ArrayList mediaUris=getArguments().getParcelableArrayList("mediaAttachments"); if(mediaUris!=null && !mediaUris.isEmpty()){ for(Uri uri:mediaUris){ - addMediaAttachment(uri, null); + mediaViewController.addMediaAttachment(uri, null); } } } @@ -805,11 +468,12 @@ public class ComposeFragment extends MastodonToolbarFragment implements OnBackPr updateCharCounter(); visibilityBtn.setEnabled(false); } + updateMediaPollStates(); } @Override public void onCreateOptionsMenu(Menu menu, MenuInflater inflater){ - inflater.inflate(R.menu.compose, menu); + inflater.inflate(editingStatus==null ? R.menu.compose : R.menu.compose_edit, menu); publishButton=menu.findItem(R.id.publish); updatePublishButtonState(); } @@ -864,21 +528,11 @@ public class ComposeFragment extends MastodonToolbarFragment implements OnBackPr updatePublishButtonState(); } - private void updatePublishButtonState(){ + public void updatePublishButtonState(){ uuid=null; - int nonEmptyPollOptionsCount=0; - for(DraftPollOption opt:pollOptions){ - if(opt.edit.length()>0) - nonEmptyPollOptionsCount++; - } if(publishButton==null) return; - int nonDoneAttachmentCount=0; - for(DraftMediaAttachment att:attachments){ - if(att.state!=AttachmentUploadState.DONE) - nonDoneAttachmentCount++; - } - publishButton.setEnabled((trimmedCharCount>0 || !attachments.isEmpty()) && charCount<=charLimit && nonDoneAttachmentCount==0 && (pollOptions.isEmpty() || nonEmptyPollOptionsCount>1)); + publishButton.setEnabled((trimmedCharCount>0 || !mediaViewController.isEmpty()) && charCount<=charLimit && mediaViewController.getNonDoneAttachmentCount()==0 && (pollViewController.isEmpty() || pollViewController.getNonEmptyOptionsCount()>1)); } private void onCustomEmojiClick(Emoji emoji){ @@ -922,31 +576,8 @@ public class ComposeFragment extends MastodonToolbarFragment implements OnBackPr publishButton.setEnabled(false); V.setVisibilityAnimated(sendProgress, View.VISIBLE); - ArrayList 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()) - actuallyPublish(); - } - @Override - public void onError(ErrorResponse error){ - handlePublishError(error); - } - }) - .exec(accountID); - updateAltTextRequests.add(req); - } - } - if(updateAltTextRequests.isEmpty()) - actuallyPublish(); + mediaViewController.saveAltTextsBeforePublishing(this::actuallyPublish, this::handlePublishError); } private void actuallyPublish(){ @@ -954,18 +585,14 @@ public class ComposeFragment extends MastodonToolbarFragment implements OnBackPr CreateStatus.Request req=new CreateStatus.Request(); req.status=text; req.visibility=statusVisibility; - if(!attachments.isEmpty()){ - req.mediaIds=attachments.stream().map(a->a.serverAttachment.id).collect(Collectors.toList()); + if(!mediaViewController.isEmpty()){ + req.mediaIds=mediaViewController.getAttachmentIDs(); } if(replyTo!=null){ req.inReplyToId=replyTo.id; } - if(!pollOptions.isEmpty()){ - req.poll=new CreateStatus.Request.Poll(); - req.poll.expiresIn=pollDuration; - req.poll.multiple=pollIsMultipleChoice; - for(DraftPollOption opt:pollOptions) - req.poll.options.add(opt.edit.getText().toString()); + if(!pollViewController.isEmpty()){ + req.poll=pollViewController.getPollForRequest(); } if(hasSpoiler && spoilerEdit.length()>0){ req.spoilerText=spoilerEdit.getText().toString(); @@ -1029,14 +656,12 @@ public class ComposeFragment extends MastodonToolbarFragment implements OnBackPr if(!mainEditText.getText().toString().equals(initialText)) return true; List existingMediaIDs=editingStatus.mediaAttachments.stream().map(a->a.id).collect(Collectors.toList()); - if(!existingMediaIDs.equals(attachments.stream().map(a->a.serverAttachment.id).collect(Collectors.toList()))) + if(!existingMediaIDs.equals(mediaViewController.getAttachmentIDs())) return true; - return pollChanged; + return pollViewController.isPollChanged(); } - boolean pollFieldsHaveContent=false; - for(DraftPollOption opt:pollOptions) - pollFieldsHaveContent|=opt.edit.length()>0; - return (mainEditText.length()>0 && !mainEditText.getText().toString().equals(initialText)) || !attachments.isEmpty() || pollFieldsHaveContent; + boolean pollFieldsHaveContent=pollViewController.getNonEmptyOptionsCount()>0; + return (mainEditText.length()>0 && !mainEditText.getText().toString().equals(initialText)) || !mediaViewController.isEmpty() || pollFieldsHaveContent; } @Override @@ -1068,14 +693,7 @@ public class ComposeFragment extends MastodonToolbarFragment implements OnBackPr if(reqCode==IMAGE_DESCRIPTION_RESULT && success){ String attID=result.getString("attachment"); String text=result.getString("text"); - for(DraftMediaAttachment att:attachments){ - if(att.serverAttachment.id.equals(attID)){ - att.descriptionSaved=false; - att.description=text; - att.setDescriptionToTitle(); - break; - } - } + mediaViewController.setAltTextByID(attID, text); } } @@ -1101,7 +719,7 @@ public class ComposeFragment extends MastodonToolbarFragment implements OnBackPr boolean usePhotoPicker=UiUtils.isPhotoPickerAvailable(); if(usePhotoPicker){ intent=new Intent(MediaStore.ACTION_PICK_IMAGES); - intent.putExtra(MediaStore.EXTRA_PICK_IMAGES_MAX, MAX_ATTACHMENTS-getMediaAttachmentsCount()); + intent.putExtra(MediaStore.EXTRA_PICK_IMAGES_MAX, mediaViewController.getMaxAttachments()-mediaViewController.getMediaAttachmentsCount()); }else{ intent=new Intent(Intent.ACTION_GET_CONTENT); intent.addCategory(Intent.CATEGORY_OPENABLE); @@ -1129,495 +747,27 @@ public class ComposeFragment extends MastodonToolbarFragment implements OnBackPr if(requestCode==MEDIA_RESULT && resultCode==Activity.RESULT_OK){ Uri single=data.getData(); if(single!=null){ - addMediaAttachment(single, null); + mediaViewController.addMediaAttachment(single, null); }else{ ClipData clipData=data.getClipData(); for(int i=0;isizeLimit){ - float mb=sizeLimit/(float) (1024*1024); - String sMb=String.format(Locale.getDefault(), mb%1f==0f ? "%.0f" : "%.2f", mb); - showMediaAttachmentError(getString(R.string.media_attachment_too_big, UiUtils.getFileName(uri), sMb)); - return false; - } - } - } - pollBtn.setEnabled(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(); - } - updatePublishButtonState(); - if(getMediaAttachmentsCount()==MAX_ATTACHMENTS) - mediaBtn.setEnabled(false); - 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(getActivity(), text, Toast.LENGTH_SHORT).show(); - attachmentsErrorShowing=true; - contentView.postDelayed(()->attachmentsErrorShowing=false, 2000); - } - } - - private View createMediaAttachmentView(DraftMediaAttachment draft){ - View thumb=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(getActivity(), R.attr.colorM3Surface)); - thumb.setOnLongClickListener(v->{ - if(!v.hasTransientState() && attachments.size()>1){ - attachmentsView.startDragging(v); - return true; - } - return false; - }); - thumb.setTag(draft); - - - 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.titleView.setText(getString(R.string.attachment_x_percent_uploaded, 0)); - draft.subtitleView.setText(getString(subtitleRes, UiUtils.formatFileSize(getActivity(), draft.fileSize, true))); - 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(ComposeFragment.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(getString(R.string.attachment_x_percent_uploaded, 0)); - } - } - - return thumb; - } - - public void addFakeMediaAttachment(Uri uri, String description){ - pollBtn.setEnabled(false); - 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=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(getString(R.string.attachment_x_percent_uploaded, Math.round(progressFraction*100f))); - - attachment.speedTracker.setTotalBytes(total); -// attachment.uploadStateTitle.setText(getString(R.string.file_upload_progress, UiUtils.formatFileSize(getActivity(), transferred, true), UiUtils.formatFileSize(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(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(getString(R.string.retry_upload)); - - V.setVisibilityAnimated(attachment.editButton, View.VISIBLE); - attachment.editButton.setImageResource(R.drawable.ic_restart_alt_24px); - attachment.editButton.setOnClickListener(ComposeFragment.this::onRetryOrCancelMediaUploadClick); - attachment.setUseErrorColors(true); - V.setVisibilityAnimated(attachment.progressBar, View.GONE); - - if(!areThereAnyUploadingAttachments()) - uploadNextQueuedAttachment(); - } - }) - .exec(accountID); - } - - 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(); - } - updatePublishButtonState(); - pollBtn.setEnabled(attachments.isEmpty()); - mediaBtn.setEnabled(true); - } - - 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(getString(R.string.cancel)); - V.setVisibilityAnimated(att.progressBar, View.VISIBLE); - V.setVisibilityAnimated(att.editButton, View.GONE); - att.titleView.setText(getString(R.string.attachment_x_percent_uploaded, 0)); - att.state=AttachmentUploadState.QUEUED; - att.setUseErrorColors(false); - if(!areThereAnyUploadingAttachments()){ - uploadNextQueuedAttachment(); - } - }else{ - onRemoveMediaAttachmentClick(v); - } - } - - 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(getActivity()!=null){ - UiUtils.runOnUiThread(attachment.processingPollingRunnable, 1000); - } - } - - @Override - public void onError(ErrorResponse error){ - attachment.processingPollingRequest=null; - if(getActivity()!=null) - UiUtils.runOnUiThread(attachment.processingPollingRunnable, 1000); - } - }) - .exec(accountID); - } - - 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(); - updatePublishButtonState(); - } - - private void uploadNextQueuedAttachment(){ - for(DraftMediaAttachment att:attachments){ - if(att.state==AttachmentUploadState.QUEUED){ - uploadMediaAttachment(att); - return; - } - } - } - - private 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", accountID); - 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(getActivity(), ComposeImageDescriptionFragment.class, args, IMAGE_DESCRIPTION_RESULT, this); + public void updateMediaPollStates(){ + pollBtn.setSelected(pollViewController.isShown()); + mediaBtn.setEnabled(!pollViewController.isShown() && mediaViewController.canAddMoreAttachments()); + pollBtn.setEnabled(mediaViewController.isEmpty()); } 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; - } + pollViewController.toggle(); 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->{ - if(!creatingView) - pollChanged=true; - updatePublishButtonState(); - })); - option.view.setOutlineProvider(OutlineProviders.roundedRect(4)); - option.view.setClipToOutline(true); - option.view.setTag(option); - - UiUtils.beginLayoutTransition(pollWrap); - pollOptionsView.addView(option.view); - pollOptions.add(option); - addPollOptionBtn.setEnabled(pollOptions.size(){ - option.view.setForeground(getResources().getDrawable(isOverLimit ? R.drawable.bg_m3_outlined_text_field_error_nopad : R.drawable.bg_m3_outlined_text_field_nopad, getActivity().getTheme())); - })); - 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(); - pollChanged=true; - } - - private void showPollDurationAlert(){ - String[] options=new String[POLL_LENGTH_OPTIONS.length]; - int selectedOption=-1; - for(int i=0;ichosenOption[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 getResources().getQuantityString(R.plurals.x_minutes, minutes, minutes); - }else if(seconds<24*3600){ - int hours=seconds/3600; - return getResources().getQuantityString(R.plurals.x_hours, hours, hours); - }else{ - int days=seconds/(24*3600); - return 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(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); + updateMediaPollStates(); } private void toggleSpoiler(){ @@ -1635,10 +785,6 @@ public class ComposeFragment extends MastodonToolbarFragment implements OnBackPr } } - private int getMediaAttachmentsCount(){ - return attachments.size(); - } - private void onVisibilityClick(View v){ PopupMenu menu=new PopupMenu(getActivity(), v); menu.inflate(R.menu.compose_visibility); @@ -1751,7 +897,7 @@ public class ComposeFragment extends MastodonToolbarFragment implements OnBackPr @Override public boolean onAddMediaAttachmentFromEditText(Uri uri, String description){ - return addMediaAttachment(uri, description); + return mediaViewController.addMediaAttachment(uri, description); } private void startAutocomplete(ComposeAutocompleteSpan span){ @@ -1788,30 +934,6 @@ public class ComposeFragment extends MastodonToolbarFragment implements OnBackPr finishAutocomplete(); } - private void loadVideoThumbIntoView(ImageView target, Uri uri){ - MastodonAPIController.runInBackground(()->{ - Context context=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); - } - }); - } - @Override public CharSequence getTitle(){ return getString(R.string.new_post); @@ -1827,113 +949,19 @@ public class ComposeFragment extends MastodonToolbarFragment implements OnBackPr return !UiUtils.isDarkTheme(); } - @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; - } + public boolean getWasDetached(){ + return wasDetached; } - enum AttachmentUploadState{ - QUEUED, - UPLOADING, - PROCESSING, - ERROR, - DONE + public boolean isCreatingView(){ + return creatingView; } - private static class DraftPollOption{ - public EditText edit; - public View view; - public View dragger; + public String getAccountID(){ + return accountID; + } + + public void addFakeMediaAttachment(Uri uri, String description){ + mediaViewController.addFakeMediaAttachment(uri, description); } } diff --git a/mastodon/src/main/java/org/joinmastodon/android/ui/TileGridLayoutManager.java b/mastodon/src/main/java/org/joinmastodon/android/ui/TileGridLayoutManager.java deleted file mode 100644 index 9062555b..00000000 --- a/mastodon/src/main/java/org/joinmastodon/android/ui/TileGridLayoutManager.java +++ /dev/null @@ -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); - } -} diff --git a/mastodon/src/main/java/org/joinmastodon/android/ui/ComposeAutocompleteViewController.java b/mastodon/src/main/java/org/joinmastodon/android/ui/viewcontrollers/ComposeAutocompleteViewController.java similarity index 98% rename from mastodon/src/main/java/org/joinmastodon/android/ui/ComposeAutocompleteViewController.java rename to mastodon/src/main/java/org/joinmastodon/android/ui/viewcontrollers/ComposeAutocompleteViewController.java index b4522c0c..c487c3a0 100644 --- a/mastodon/src/main/java/org/joinmastodon/android/ui/ComposeAutocompleteViewController.java +++ b/mastodon/src/main/java/org/joinmastodon/android/ui/viewcontrollers/ComposeAutocompleteViewController.java @@ -1,11 +1,9 @@ -package org.joinmastodon.android.ui; +package org.joinmastodon.android.ui.viewcontrollers; import android.annotation.SuppressLint; import android.app.Activity; import android.graphics.Rect; -import android.graphics.Typeface; import android.graphics.drawable.Drawable; -import android.text.TextUtils; import android.view.Gravity; import android.view.View; 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.Hashtag; 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.utils.CustomEmojiHelper; import org.joinmastodon.android.ui.utils.UiUtils; diff --git a/mastodon/src/main/java/org/joinmastodon/android/ui/viewcontrollers/ComposeMediaViewController.java b/mastodon/src/main/java/org/joinmastodon/android/ui/viewcontrollers/ComposeMediaViewController.java new file mode 100644 index 00000000..68fe19cb --- /dev/null +++ b/mastodon/src/main/java/org/joinmastodon/android/ui/viewcontrollers/ComposeMediaViewController.java @@ -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 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 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 getAttachmentIDs(){ + return attachments.stream().map(a->a.serverAttachment.id).collect(Collectors.toList()); + } + + public boolean isEmpty(){ + return attachments.isEmpty(); + } + + public boolean canAddMoreAttachments(){ + return attachments.size() onError){ + ArrayList 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 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 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(); + } + } +} diff --git a/mastodon/src/main/java/org/joinmastodon/android/ui/viewcontrollers/ComposePollViewController.java b/mastodon/src/main/java/org/joinmastodon/android/ui/viewcontrollers/ComposePollViewController.java new file mode 100644 index 00000000..4a369433 --- /dev/null +++ b/mastodon/src/main/java/org/joinmastodon/android/ui/viewcontrollers/ComposePollViewController.java @@ -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 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 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(){ + 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;ichosenOption[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 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()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(); + } + } + } +} diff --git a/mastodon/src/main/res/menu/compose_edit.xml b/mastodon/src/main/res/menu/compose_edit.xml new file mode 100644 index 00000000..0627ef7a --- /dev/null +++ b/mastodon/src/main/res/menu/compose_edit.xml @@ -0,0 +1,4 @@ + + + + \ No newline at end of file diff --git a/mastodon/src/main/res/values/strings.xml b/mastodon/src/main/res/values/strings.xml index 63a836af..ccc415c6 100644 --- a/mastodon/src/main/res/values/strings.xml +++ b/mastodon/src/main/res/values/strings.xml @@ -462,6 +462,11 @@ %s video %s audio %s file + Image + Video + Audio + GIF + File %d%% uploaded Add poll option Poll length @@ -474,4 +479,5 @@ Help What is alt text? 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
  • Capture important elements
  • \n
  • Summarize text in images
  • \n
  • Use regular sentence structure
  • \n
  • Avoid redundant information
  • \n
  • Focus on trends and key findings in complex visuals (like diagrams or maps)
+ Edit post \ No newline at end of file