diff --git a/mastodon/build.gradle b/mastodon/build.gradle index 22671db0c..6ea688219 100644 --- a/mastodon/build.gradle +++ b/mastodon/build.gradle @@ -10,7 +10,7 @@ android { applicationId "org.joinmastodon.android" minSdk 23 targetSdk 31 - versionCode 12 + versionCode 13 versionName "0.1" } diff --git a/mastodon/src/main/java/org/joinmastodon/android/api/requests/statuses/UpdateAttachment.java b/mastodon/src/main/java/org/joinmastodon/android/api/requests/statuses/UpdateAttachment.java new file mode 100644 index 000000000..0128cd39b --- /dev/null +++ b/mastodon/src/main/java/org/joinmastodon/android/api/requests/statuses/UpdateAttachment.java @@ -0,0 +1,19 @@ +package org.joinmastodon.android.api.requests.statuses; + +import org.joinmastodon.android.api.MastodonAPIRequest; +import org.joinmastodon.android.model.Attachment; + +public class UpdateAttachment extends MastodonAPIRequest{ + public UpdateAttachment(String id, String description){ + super(HttpMethod.PUT, "/media/"+id, Attachment.class); + setRequestBody(new Body(description)); + } + + private static class Body{ + public String description; + + public Body(String description){ + this.description=description; + } + } +} 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 bd27e8e88..84b6c2b87 100644 --- a/mastodon/src/main/java/org/joinmastodon/android/fragments/ComposeFragment.java +++ b/mastodon/src/main/java/org/joinmastodon/android/fragments/ComposeFragment.java @@ -12,6 +12,7 @@ import android.icu.text.BreakIterator; import android.net.Uri; import android.os.Build; import android.os.Bundle; +import android.os.Parcelable; import android.text.Editable; import android.text.TextUtils; import android.text.TextWatcher; @@ -33,6 +34,7 @@ import android.widget.LinearLayout; import android.widget.PopupMenu; import android.widget.ProgressBar; import android.widget.TextView; +import android.widget.Toast; import com.twitter.twittertext.Regex; import com.twitter.twittertext.TwitterTextEmojiRegex; @@ -58,8 +60,11 @@ import org.joinmastodon.android.ui.PopupKeyboard; import org.joinmastodon.android.ui.drawables.SpoilerStripesDrawable; import org.joinmastodon.android.ui.text.HtmlParser; import org.joinmastodon.android.ui.utils.SimpleTextWatcher; +import org.joinmastodon.android.ui.utils.UiUtils; +import org.joinmastodon.android.ui.views.ComposeMediaLayout; import org.joinmastodon.android.ui.views.ReorderableLinearLayout; import org.joinmastodon.android.ui.views.SizeListenerLinearLayout; +import org.parceler.Parcel; import org.parceler.Parcels; import java.util.ArrayList; @@ -80,7 +85,9 @@ import me.grishka.appkit.utils.V; public class ComposeFragment extends ToolbarFragment implements OnBackPressedListener{ private static final int MEDIA_RESULT=717; + private static final int IMAGE_DESCRIPTION_RESULT=363; private static final int MAX_POLL_OPTIONS=4; + private static final int MAX_ATTACHMENTS=4; private static final Pattern MENTION_PATTERN=Pattern.compile("(^|[^\\/\\w])@(([a-z0-9_]+)@[a-z0-9\\.\\-]+[a-z0-9]+)", Pattern.CASE_INSENSITIVE); @@ -115,7 +122,7 @@ public class ComposeFragment extends ToolbarFragment implements OnBackPressedLis private Button publishButton; private ImageButton mediaBtn, pollBtn, emojiBtn, spoilerBtn, visibilityBtn; - private LinearLayout attachmentsView; + private ComposeMediaLayout attachmentsView; private TextView replyText; private ReorderableLinearLayout pollOptionsView; private View pollWrap; @@ -124,7 +131,7 @@ public class ComposeFragment extends ToolbarFragment implements OnBackPressedLis private ArrayList pollOptions=new ArrayList<>(); - private ArrayList queuedAttachments=new ArrayList<>(), failedAttachments=new ArrayList<>(), attachments=new ArrayList<>(); + private ArrayList queuedAttachments=new ArrayList<>(), failedAttachments=new ArrayList<>(), attachments=new ArrayList<>(), allAttachments=new ArrayList<>(); private DraftMediaAttachment uploadingAttachment; private List customEmojis; @@ -244,7 +251,20 @@ public class ComposeFragment extends ToolbarFragment implements OnBackPressedLis spoilerBtn.setSelected(true); } - // TODO save and restore media attachments (when design is ready) + if(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); + } + attachmentsView.setVisibility(View.VISIBLE); + }else if(!allAttachments.isEmpty()){ + attachmentsView.setVisibility(View.VISIBLE); + for(DraftMediaAttachment att:allAttachments){ + attachmentsView.addView(createMediaAttachmentView(att)); + } + } return view; } @@ -261,6 +281,13 @@ public class ComposeFragment extends ToolbarFragment implements OnBackPressedLis outState.putInt("pollDuration", pollDuration); outState.putString("pollDurationStr", pollDurationStr); 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); + } } } @@ -473,6 +500,21 @@ public class ComposeFragment extends ToolbarFragment implements OnBackPressedLis } } + @Override + public void onFragmentResult(int reqCode, boolean success, Bundle result){ + if(reqCode==IMAGE_DESCRIPTION_RESULT && success){ + Attachment updated=Parcels.unwrap(result.getParcelable("attachment")); + for(DraftMediaAttachment att:attachments){ + if(att.serverAttachment.id.equals(updated.id)){ + att.serverAttachment=updated; + att.description=updated.description; + att.descriptionView.setText(att.description); + break; + } + } + } + } + private void confirmDiscardDraftAndFinish(){ new M3AlertDialogBuilder(getActivity()) .setTitle(R.string.discard_draft) @@ -506,26 +548,61 @@ public class ComposeFragment extends ToolbarFragment implements OnBackPressedLis } private void addMediaAttachment(Uri uri){ + if(getMediaAttachmentsCount()==MAX_ATTACHMENTS) + return; pollBtn.setEnabled(false); - View thumb=getActivity().getLayoutInflater().inflate(R.layout.compose_media_thumb, attachmentsView, false); - ImageView img=thumb.findViewById(R.id.thumb); - ViewImageLoader.load(img, null, new UrlImageLoaderRequest(uri, V.dp(250), V.dp(250))); - attachmentsView.addView(thumb); - DraftMediaAttachment draft=new DraftMediaAttachment(); draft.uri=uri; - draft.view=thumb; - draft.progressBar=thumb.findViewById(R.id.progress); - Button btn=thumb.findViewById(R.id.remove_btn); - btn.setTag(draft); - btn.setOnClickListener(this::onRemoveMediaAttachmentClick); + attachmentsView.addView(createMediaAttachmentView(draft)); + allAttachments.add(draft); + attachmentsView.setVisibility(View.VISIBLE); if(uploadingAttachment==null){ uploadMediaAttachment(draft); }else{ queuedAttachments.add(draft); } updatePublishButtonState(); + if(getMediaAttachmentsCount()==MAX_ATTACHMENTS) + mediaBtn.setEnabled(false); + } + + private View createMediaAttachmentView(DraftMediaAttachment draft){ + View thumb=getActivity().getLayoutInflater().inflate(R.layout.compose_media_thumb, attachmentsView, false); + ImageView img=thumb.findViewById(R.id.thumb); + ViewImageLoader.load(img, null, new UrlImageLoaderRequest(draft.uri, V.dp(250), V.dp(250))); + TextView fileName=thumb.findViewById(R.id.file_name); + fileName.setText(UiUtils.getFileName(draft.uri)); + + draft.view=thumb; + draft.progressBar=thumb.findViewById(R.id.progress); + draft.infoBar=thumb.findViewById(R.id.info_bar); + draft.errorOverlay=thumb.findViewById(R.id.error_overlay); + draft.descriptionView=thumb.findViewById(R.id.description); + ImageButton btn=thumb.findViewById(R.id.remove_btn); + btn.setTag(draft); + btn.setOnClickListener(this::onRemoveMediaAttachmentClick); + btn=thumb.findViewById(R.id.remove_btn2); + btn.setTag(draft); + btn.setOnClickListener(this::onRemoveMediaAttachmentClick); + Button retry=thumb.findViewById(R.id.retry_upload); + retry.setTag(draft); + retry.setOnClickListener(this::onRetryMediaUploadClick); + draft.infoBar.setTag(draft); + draft.infoBar.setOnClickListener(this::onEditMediaDescriptionClick); + + if(!TextUtils.isEmpty(draft.description)) + draft.descriptionView.setText(draft.description); + + if(uploadingAttachment!=draft && !queuedAttachments.contains(draft)){ + draft.progressBar.setVisibility(View.GONE); + } + if(failedAttachments.contains(draft)){ + draft.infoBar.setVisibility(View.GONE); + draft.errorOverlay.setVisibility(View.VISIBLE); + } + + return thumb; } private void uploadMediaAttachment(DraftMediaAttachment attachment){ @@ -561,8 +638,11 @@ public class ComposeFragment extends ToolbarFragment implements OnBackPressedLis attachment.uploadRequest=null; uploadingAttachment=null; failedAttachments.add(attachment); - error.showToast(getActivity()); - // TODO show the error state in the attachment view +// error.showToast(getActivity()); + Toast.makeText(getActivity(), R.string.image_upload_failed, Toast.LENGTH_SHORT).show(); + + V.setVisibilityAnimated(attachment.errorOverlay, View.VISIBLE); + V.setVisibilityAnimated(attachment.infoBar, View.GONE); if(!queuedAttachments.isEmpty()) uploadMediaAttachment(queuedAttachments.remove(0)); @@ -584,9 +664,38 @@ public class ComposeFragment extends ToolbarFragment implements OnBackPressedLis queuedAttachments.remove(att); failedAttachments.remove(att); } + allAttachments.remove(att); attachmentsView.removeView(att.view); + if(getMediaAttachmentsCount()==0) + attachmentsView.setVisibility(View.GONE); updatePublishButtonState(); pollBtn.setEnabled(attachments.isEmpty() && queuedAttachments.isEmpty() && failedAttachments.isEmpty() && uploadingAttachment==null); + mediaBtn.setEnabled(true); + } + + private void onRetryMediaUploadClick(View v){ + DraftMediaAttachment att=(DraftMediaAttachment) v.getTag(); + if(failedAttachments.remove(att)){ + V.setVisibilityAnimated(att.errorOverlay, View.GONE); + V.setVisibilityAnimated(att.infoBar, View.VISIBLE); + V.setVisibilityAnimated(att.progressBar, View.VISIBLE); + if(uploadingAttachment==null) + uploadMediaAttachment(att); + else + queuedAttachments.add(att); + } + } + + 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); + Nav.goForResult(getActivity(), ComposeImageDescriptionFragment.class, args, IMAGE_DESCRIPTION_RESULT, this); } private void togglePoll(){ @@ -680,13 +789,22 @@ public class ComposeFragment extends ToolbarFragment implements OnBackPressedLis } } - private static class DraftMediaAttachment{ + private int getMediaAttachmentsCount(){ + return allAttachments.size(); + } + + @Parcel + static class DraftMediaAttachment{ public Attachment serverAttachment; public Uri uri; - public UploadAttachment uploadRequest; + public transient UploadAttachment uploadRequest; + public String description; - public View view; - public ProgressBar progressBar; + public transient View view; + public transient ProgressBar progressBar; + public transient TextView descriptionView; + public transient View errorOverlay; + public transient View infoBar; } private static class DraftPollOption{ diff --git a/mastodon/src/main/java/org/joinmastodon/android/fragments/ComposeImageDescriptionFragment.java b/mastodon/src/main/java/org/joinmastodon/android/fragments/ComposeImageDescriptionFragment.java new file mode 100644 index 000000000..20e3553c0 --- /dev/null +++ b/mastodon/src/main/java/org/joinmastodon/android/fragments/ComposeImageDescriptionFragment.java @@ -0,0 +1,113 @@ +package org.joinmastodon.android.fragments; + +import android.app.Activity; +import android.content.res.TypedArray; +import android.net.Uri; +import android.os.Bundle; +import android.view.Gravity; +import android.view.LayoutInflater; +import android.view.Menu; +import android.view.MenuInflater; +import android.view.MenuItem; +import android.view.View; +import android.view.ViewGroup; +import android.view.inputmethod.InputMethodManager; +import android.widget.Button; +import android.widget.EditText; +import android.widget.FrameLayout; +import android.widget.ImageView; + +import org.joinmastodon.android.R; +import org.joinmastodon.android.api.requests.statuses.UpdateAttachment; +import org.joinmastodon.android.model.Attachment; +import org.parceler.Parcels; + +import me.grishka.appkit.Nav; +import me.grishka.appkit.api.Callback; +import me.grishka.appkit.api.ErrorResponse; +import me.grishka.appkit.fragments.ToolbarFragment; +import me.grishka.appkit.imageloader.ViewImageLoader; +import me.grishka.appkit.imageloader.requests.UrlImageLoaderRequest; +import me.grishka.appkit.utils.V; + +public class ComposeImageDescriptionFragment extends ToolbarFragment{ + private String accountID, attachmentID; + private EditText edit; + private Button saveButton; + + @Override + public void onCreate(Bundle savedInstanceState){ + super.onCreate(savedInstanceState); + accountID=getArguments().getString("account"); + attachmentID=getArguments().getString("attachment"); + setHasOptionsMenu(true); + } + + @Override + public void onAttach(Activity activity){ + super.onAttach(activity); + setTitle(R.string.edit_image); + } + + @Override + public View onCreateContentView(LayoutInflater inflater, ViewGroup container, Bundle savedInstanceState){ + View view=inflater.inflate(R.layout.fragment_image_description, container, false); + + edit=view.findViewById(R.id.edit); + ImageView image=view.findViewById(R.id.photo); + Uri uri=getArguments().getParcelable("uri"); + ViewImageLoader.load(image, null, new UrlImageLoaderRequest(uri, 1000, 1000)); + edit.setText(getArguments().getString("existingDescription")); + + return view; + } + + @Override + public void onViewCreated(View view, Bundle savedInstanceState){ + super.onViewCreated(view, savedInstanceState); + edit.requestFocus(); + view.postDelayed(()->getActivity().getSystemService(InputMethodManager.class).showSoftInput(edit, 0), 100); + } + + @Override + public void onCreateOptionsMenu(Menu menu, MenuInflater inflater){ + TypedArray ta=getActivity().obtainStyledAttributes(new int[]{R.attr.secondaryButtonStyle}); + int buttonStyle=ta.getResourceId(0, 0); + ta.recycle(); + saveButton=new Button(getActivity(), null, 0, buttonStyle); + saveButton.setText(R.string.save); + saveButton.setOnClickListener(this::onSaveClick); + FrameLayout wrap=new FrameLayout(getActivity()); + wrap.addView(saveButton, new FrameLayout.LayoutParams(ViewGroup.LayoutParams.WRAP_CONTENT, ViewGroup.LayoutParams.WRAP_CONTENT, Gravity.TOP|Gravity.LEFT)); + wrap.setPadding(V.dp(16), V.dp(4), V.dp(16), V.dp(8)); + wrap.setClipToPadding(false); + MenuItem item=menu.add(R.string.publish); + item.setActionView(wrap); + item.setShowAsAction(MenuItem.SHOW_AS_ACTION_ALWAYS); + } + + @Override + public boolean onOptionsItemSelected(MenuItem item){ + return true; + } + + private void onSaveClick(View v){ + new UpdateAttachment(attachmentID, edit.getText().toString().trim()) + .setCallback(new Callback<>(){ + @Override + public void onSuccess(Attachment result){ + Bundle r=new Bundle(); + r.putParcelable("attachment", Parcels.wrap(result)); + setResult(true, r); + Nav.finish(ComposeImageDescriptionFragment.this); + } + + @Override + public void onError(ErrorResponse error){ + error.showToast(getActivity()); + } + }) + .wrapProgress(getActivity(), R.string.saving, false) + .exec(accountID); + } +} diff --git a/mastodon/src/main/java/org/joinmastodon/android/ui/views/ComposeMediaLayout.java b/mastodon/src/main/java/org/joinmastodon/android/ui/views/ComposeMediaLayout.java new file mode 100644 index 000000000..383bd0cdb --- /dev/null +++ b/mastodon/src/main/java/org/joinmastodon/android/ui/views/ComposeMediaLayout.java @@ -0,0 +1,97 @@ +package org.joinmastodon.android.ui.views; + +import android.annotation.SuppressLint; +import android.content.Context; +import android.util.AttributeSet; +import android.view.View; +import android.view.ViewGroup; + +import me.grishka.appkit.utils.V; + +public class ComposeMediaLayout extends ViewGroup{ + private static final int MAX_WIDTH_DP=400; + private static final int GAP_DP=8; + private static final float ASPECT_RATIO=0.5625f; + + public ComposeMediaLayout(Context context){ + this(context, null); + } + + public ComposeMediaLayout(Context context, AttributeSet attrs){ + this(context, attrs, 0); + } + + public ComposeMediaLayout(Context context, AttributeSet attrs, int defStyle){ + super(context, attrs, defStyle); + } + + @Override + protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec){ + int mode=MeasureSpec.getMode(widthMeasureSpec); + @SuppressLint("SwitchIntDef") + int width=switch(mode){ + case MeasureSpec.AT_MOST -> Math.min(V.dp(MAX_WIDTH_DP), MeasureSpec.getSize(widthMeasureSpec)); + case MeasureSpec.EXACTLY -> MeasureSpec.getSize(widthMeasureSpec); + default -> throw new IllegalArgumentException("unsupported measure mode"); + }; + int height=Math.round(width*ASPECT_RATIO); + setMeasuredDimension(width, height); + + // We don't really need this, but some layouts will freak out if you don't measure them + int childWidth, firstChildHeight, otherChildrenHeight=0; + int gap=V.dp(GAP_DP); + switch(getChildCount()){ + case 0 -> { + return; + } + case 1 -> { + childWidth=width; + firstChildHeight=height; + } + case 2 -> { + childWidth=(width-gap)/2; + firstChildHeight=otherChildrenHeight=height; + } + case 3 -> { + childWidth=(width-gap)/2; + firstChildHeight=height; + otherChildrenHeight=(height-gap)/2; + } + default -> { + childWidth=(width-gap)/2; + firstChildHeight=otherChildrenHeight=(height-gap)/2; + } + } + for(int i=0;i {} + case 1 -> getChildAt(0).layout(0, 0, width, height); + case 2 -> { + getChildAt(0).layout(0, 0, halfWidth, height); + getChildAt(1).layout(halfWidth+gap, 0, width, height); + } + case 3 -> { + getChildAt(0).layout(0, 0, halfWidth, height); + getChildAt(1).layout(halfWidth+gap, 0, width, halfHeight); + getChildAt(2).layout(halfWidth+gap, halfHeight+gap, width, height); + } + default -> { + getChildAt(0).layout(0, 0, halfWidth, halfHeight); + getChildAt(1).layout(halfWidth+gap, 0, width, halfHeight); + getChildAt(2).layout(0, halfHeight+gap, halfWidth, height); + getChildAt(3).layout(halfWidth+gap, halfHeight+gap, width, height); + } + } + } +} diff --git a/mastodon/src/main/res/drawable/ic_fluent_delete_20_regular.xml b/mastodon/src/main/res/drawable/ic_fluent_delete_20_regular.xml new file mode 100644 index 000000000..a6ffec222 --- /dev/null +++ b/mastodon/src/main/res/drawable/ic_fluent_delete_20_regular.xml @@ -0,0 +1,3 @@ + + + diff --git a/mastodon/src/main/res/drawable/ic_fluent_delete_28_regular.xml b/mastodon/src/main/res/drawable/ic_fluent_delete_28_regular.xml new file mode 100644 index 000000000..b2a53dd2e --- /dev/null +++ b/mastodon/src/main/res/drawable/ic_fluent_delete_28_regular.xml @@ -0,0 +1,3 @@ + + + diff --git a/mastodon/src/main/res/layout/compose_media_thumb.xml b/mastodon/src/main/res/layout/compose_media_thumb.xml index 5812a0155..6bfc7df26 100644 --- a/mastodon/src/main/res/layout/compose_media_thumb.xml +++ b/mastodon/src/main/res/layout/compose_media_thumb.xml @@ -1,5 +1,6 @@ @@ -7,20 +8,93 @@ android:id="@+id/thumb" android:layout_width="match_parent" android:layout_height="match_parent" - android:scaleType="centerCrop"/> - -