From 45cc531eec23a609a8e164a14063acf8453b8c0c Mon Sep 17 00:00:00 2001 From: Grishka Date: Tue, 14 Nov 2023 21:27:15 +0300 Subject: [PATCH] Thread fragment tweaks part 2 --- .../android/fragments/ComposeFragment.java | 38 ++- .../android/fragments/ThreadFragment.java | 51 +++- .../org/joinmastodon/android/ui/Snackbar.java | 217 ++++++++++++++++++ .../ExtendedFooterStatusDisplayItem.java | 14 +- .../src/main/res/drawable/bg_rect_ripple.xml | 8 + .../src/main/res/layout/fragment_thread.xml | 81 +++++++ mastodon/src/main/res/values/attrs.xml | 2 + mastodon/src/main/res/values/strings.xml | 2 + mastodon/src/main/res/values/styles.xml | 4 + 9 files changed, 412 insertions(+), 5 deletions(-) create mode 100644 mastodon/src/main/java/org/joinmastodon/android/ui/Snackbar.java create mode 100644 mastodon/src/main/res/drawable/bg_rect_ripple.xml create mode 100644 mastodon/src/main/res/layout/fragment_thread.xml 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 8322d042d..919e68349 100644 --- a/mastodon/src/main/java/org/joinmastodon/android/fragments/ComposeFragment.java +++ b/mastodon/src/main/java/org/joinmastodon/android/fragments/ComposeFragment.java @@ -1,5 +1,8 @@ package org.joinmastodon.android.fragments; +import android.animation.Animator; +import android.animation.AnimatorSet; +import android.animation.ObjectAnimator; import android.annotation.SuppressLint; import android.app.Activity; import android.content.ClipData; @@ -90,12 +93,14 @@ 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.fragments.CustomTransitionsFragment; 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{ +public class ComposeFragment extends MastodonToolbarFragment implements OnBackPressedListener, ComposeEditText.SelectionListener, CustomTransitionsFragment{ private static final int MEDIA_RESULT=717; public static final int IMAGE_DESCRIPTION_RESULT=363; @@ -1124,4 +1129,35 @@ public class ComposeFragment extends MastodonToolbarFragment implements OnBackPr private void setPostLanguage(ComposeLanguageAlertViewController.SelectedOption language){ postLang=language; } + + @Override + public Animator onCreateEnterTransition(View prev, View container){ + AnimatorSet anim=new AnimatorSet(); + if(getArguments().getBoolean("fromThreadFragment")){ + anim.playTogether( + ObjectAnimator.ofFloat(container, View.ALPHA, 0f, 1f), + ObjectAnimator.ofFloat(container, View.TRANSLATION_Y, V.dp(200), 0) + ); + }else{ + anim.playTogether( + ObjectAnimator.ofFloat(container, View.ALPHA, 0f, 1f), + ObjectAnimator.ofFloat(container, View.TRANSLATION_X, V.dp(100), 0) + ); + } + anim.setDuration(300); + anim.setInterpolator(CubicBezierInterpolator.DEFAULT); + return anim; + } + + @Override + public Animator onCreateExitTransition(View prev, View container){ + AnimatorSet anim=new AnimatorSet(); + anim.playTogether( + ObjectAnimator.ofFloat(container, View.TRANSLATION_X, V.dp(100)), + ObjectAnimator.ofFloat(container, View.ALPHA, 0) + ); + anim.setDuration(200); + anim.setInterpolator(CubicBezierInterpolator.DEFAULT); + return anim; + } } diff --git a/mastodon/src/main/java/org/joinmastodon/android/fragments/ThreadFragment.java b/mastodon/src/main/java/org/joinmastodon/android/fragments/ThreadFragment.java index 6d274d9ae..c9c405c91 100644 --- a/mastodon/src/main/java/org/joinmastodon/android/fragments/ThreadFragment.java +++ b/mastodon/src/main/java/org/joinmastodon/android/fragments/ThreadFragment.java @@ -2,19 +2,24 @@ package org.joinmastodon.android.fragments; import android.content.res.ColorStateList; import android.os.Bundle; +import android.text.TextUtils; import android.view.View; import android.view.ViewGroup; +import android.view.WindowInsets; +import android.widget.FrameLayout; import android.widget.ImageView; +import android.widget.LinearLayout; +import android.widget.TextView; +import org.joinmastodon.android.GlobalUserPreferences; import org.joinmastodon.android.R; import org.joinmastodon.android.api.requests.statuses.GetStatusContext; import org.joinmastodon.android.api.session.AccountSessionManager; -import org.joinmastodon.android.events.StatusCreatedEvent; import org.joinmastodon.android.model.Account; import org.joinmastodon.android.model.FilterContext; -import org.joinmastodon.android.model.LegacyFilter; import org.joinmastodon.android.model.Status; import org.joinmastodon.android.model.StatusContext; +import org.joinmastodon.android.ui.OutlineProviders; import org.joinmastodon.android.ui.displayitems.ExtendedFooterStatusDisplayItem; import org.joinmastodon.android.ui.displayitems.FooterStatusDisplayItem; import org.joinmastodon.android.ui.displayitems.SpoilerStatusDisplayItem; @@ -26,10 +31,12 @@ import org.parceler.Parcels; import java.util.Collections; import java.util.List; -import java.util.stream.Collectors; import androidx.recyclerview.widget.RecyclerView; +import me.grishka.appkit.Nav; import me.grishka.appkit.api.SimpleCallback; +import me.grishka.appkit.imageloader.ViewImageLoader; +import me.grishka.appkit.imageloader.requests.UrlImageLoaderRequest; import me.grishka.appkit.utils.MergeRecyclerAdapter; import me.grishka.appkit.utils.SingleViewRecyclerAdapter; import me.grishka.appkit.utils.V; @@ -37,10 +44,16 @@ import me.grishka.appkit.utils.V; public class ThreadFragment extends StatusListFragment{ private Status mainStatus; private ImageView endMark; + private FrameLayout replyContainer; + private LinearLayout replyButton; + private ImageView replyButtonAva; + private TextView replyButtonText; + private int lastBottomInset; @Override public void onCreate(Bundle savedInstanceState){ super.onCreate(savedInstanceState); + setLayout(R.layout.fragment_thread); mainStatus=Parcels.unwrap(getArguments().getParcelable("status")); Account inReplyToAccount=Parcels.unwrap(getArguments().getParcelable("inReplyToAccount")); if(inReplyToAccount!=null) @@ -126,6 +139,20 @@ public class ThreadFragment extends StatusListFragment{ @Override public void onViewCreated(View view, Bundle savedInstanceState){ super.onViewCreated(view, savedInstanceState); + replyContainer=view.findViewById(R.id.reply_button_wrapper); + replyButton=replyContainer.findViewById(R.id.reply_button); + replyButtonText=replyButton.findViewById(R.id.reply_btn_text); + replyButtonAva=replyButton.findViewById(R.id.avatar); + replyButton.setOutlineProvider(OutlineProviders.roundedRect(20)); + replyButton.setClipToOutline(true); + replyButtonText.setText(getString(R.string.reply_to_user, mainStatus.account.displayName)); + replyButtonAva.setOutlineProvider(OutlineProviders.OVAL); + replyButtonAva.setClipToOutline(true); + replyButton.setOnClickListener(v->openReply()); + Account self=AccountSessionManager.get(accountID).self; + if(!TextUtils.isEmpty(self.avatar)){ + ViewImageLoader.loadWithoutAnimation(replyButtonAva, getResources().getDrawable(R.drawable.image_placeholder), new UrlImageLoaderRequest(self.avatar, V.dp(24), V.dp(24))); + } UiUtils.loadCustomEmojiInTextView(toolbarTitleView); showContent(); if(!loaded) @@ -175,4 +202,22 @@ public class ThreadFragment extends StatusListFragment{ } super.onErrorRetryClick(); } + + @Override + public void onApplyWindowInsets(WindowInsets insets){ + lastBottomInset=insets.getSystemWindowInsetBottom(); + super.onApplyWindowInsets(UiUtils.applyBottomInsetToFixedView(replyContainer, insets)); + } + + private void openReply(){ + Bundle args=new Bundle(); + args.putString("account", accountID); + args.putParcelable("replyTo", Parcels.wrap(mainStatus)); + args.putBoolean("fromThreadFragment", true); + Nav.go(getActivity(), ComposeFragment.class, args); + } + + public int getSnackbarOffset(){ + return replyContainer.getHeight()-lastBottomInset; + } } diff --git a/mastodon/src/main/java/org/joinmastodon/android/ui/Snackbar.java b/mastodon/src/main/java/org/joinmastodon/android/ui/Snackbar.java new file mode 100644 index 000000000..7ee499756 --- /dev/null +++ b/mastodon/src/main/java/org/joinmastodon/android/ui/Snackbar.java @@ -0,0 +1,217 @@ +package org.joinmastodon.android.ui; + +import android.animation.Animator; +import android.animation.AnimatorListenerAdapter; +import android.animation.AnimatorSet; +import android.animation.ObjectAnimator; +import android.content.Context; +import android.content.res.ColorStateList; +import android.graphics.Outline; +import android.graphics.PixelFormat; +import android.text.TextUtils; +import android.view.Gravity; +import android.view.View; +import android.view.ViewGroup; +import android.view.ViewOutlineProvider; +import android.view.ViewTreeObserver; +import android.view.WindowManager; +import android.view.animation.AnimationUtils; +import android.widget.Button; +import android.widget.FrameLayout; +import android.widget.LinearLayout; +import android.widget.TextView; + +import org.joinmastodon.android.R; +import org.joinmastodon.android.ui.utils.UiUtils; + +import androidx.annotation.Keep; +import androidx.annotation.StringRes; +import me.grishka.appkit.utils.CubicBezierInterpolator; +import me.grishka.appkit.utils.V; + +public class Snackbar{ + private static Snackbar current; + + private final Context context; + private int bottomOffset; + private FrameLayout windowView; + private LinearLayout contentView; + private boolean hasAction; + private AnimatableOutlineProvider outlineProvider; + private Animator currentAnim; + private Runnable dismissRunnable=this::dismiss; + + private Snackbar(Context context, String text, String action, Runnable onActionClick, int bottomOffset){ + this.context=context; + this.bottomOffset=bottomOffset; + hasAction=onActionClick!=null; + + windowView=new FrameLayout(context); + windowView.setClipToPadding(false); + contentView=new LinearLayout(context); + contentView.setOrientation(LinearLayout.HORIZONTAL); + contentView.setBaselineAligned(false); + contentView.setBackgroundColor(UiUtils.getThemeColor(context, R.attr.colorM3SurfaceInverse)); + contentView.setOutlineProvider(outlineProvider=new AnimatableOutlineProvider(contentView)); + contentView.setClipToOutline(true); + contentView.setElevation(V.dp(6)); + contentView.setPaddingRelative(V.dp(16), 0, V.dp(8), 0); + FrameLayout.LayoutParams lp=new FrameLayout.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.WRAP_CONTENT); + lp.leftMargin=lp.topMargin=lp.rightMargin=lp.bottomMargin=V.dp(16); + windowView.addView(contentView, lp); + + TextView textView=new TextView(context); + textView.setTextAppearance(R.style.m3_body_medium); + textView.setTextColor(UiUtils.getThemeColor(context, R.attr.colorM3OnSurfaceInverse)); + textView.setMaxLines(2); + textView.setEllipsize(TextUtils.TruncateAt.END); + textView.setText(text); + textView.setMinHeight(V.dp(48)); + textView.setGravity(Gravity.CENTER_VERTICAL | Gravity.START); + textView.setPadding(0, V.dp(14), 0, V.dp(14)); + contentView.addView(textView, new LinearLayout.LayoutParams(0, ViewGroup.LayoutParams.WRAP_CONTENT, 1f)); + + if(action!=null){ + Button button=new Button(context); + int primaryInverse=UiUtils.getThemeColor(context, R.attr.colorM3PrimaryInverse); + button.setTextColor(primaryInverse); + button.setBackgroundResource(R.drawable.bg_rect_4dp_ripple); + button.setBackgroundTintList(ColorStateList.valueOf(primaryInverse)); + button.setText(action); + button.setPadding(V.dp(8), 0, V.dp(8), 0); + button.setOnClickListener(v->onActionClick.run()); + LinearLayout.LayoutParams blp=new LinearLayout.LayoutParams(ViewGroup.LayoutParams.WRAP_CONTENT, V.dp(40)); + blp.leftMargin=blp.topMargin=blp.rightMargin=blp.bottomMargin=V.dp(4); + contentView.addView(button, blp); + } + } + + public void show(){ + if(current!=null) + current.dismiss(); + current=this; + WindowManager.LayoutParams lp=new WindowManager.LayoutParams(WindowManager.LayoutParams.TYPE_APPLICATION_PANEL, WindowManager.LayoutParams.FLAG_NOT_FOCUSABLE | WindowManager.LayoutParams.FLAG_LAYOUT_IN_SCREEN, PixelFormat.TRANSLUCENT); + lp.width=ViewGroup.LayoutParams.MATCH_PARENT; + lp.height=ViewGroup.LayoutParams.WRAP_CONTENT; + lp.gravity=Gravity.BOTTOM; + lp.y=bottomOffset; + WindowManager wm=context.getSystemService(WindowManager.class); + wm.addView(windowView, lp); + windowView.getViewTreeObserver().addOnPreDrawListener(new ViewTreeObserver.OnPreDrawListener(){ + @Override + public boolean onPreDraw(){ + windowView.getViewTreeObserver().removeOnPreDrawListener(this); + + AnimatorSet set=new AnimatorSet(); + set.playTogether( + ObjectAnimator.ofFloat(outlineProvider, "fraction", 0, 1), + ObjectAnimator.ofFloat(contentView, View.ALPHA, 0, 1) + ); + set.setInterpolator(AnimationUtils.loadInterpolator(context, R.interpolator.m3_sys_motion_easing_standard_decelerate)); + set.setDuration(350); + set.addListener(new AnimatorListenerAdapter(){ + @Override + public void onAnimationEnd(Animator animation){ + currentAnim=null; + } + }); + currentAnim=set; + set.start(); + + return true; + } + }); + windowView.postDelayed(dismissRunnable, 4000); + } + + public void dismiss(){ + current=null; + if(currentAnim!=null){ + currentAnim.cancel(); + } + windowView.removeCallbacks(dismissRunnable); + ObjectAnimator anim=ObjectAnimator.ofFloat(contentView, View.ALPHA, 0); + anim.setInterpolator(CubicBezierInterpolator.DEFAULT); + anim.setDuration(200); + anim.addListener(new AnimatorListenerAdapter(){ + @Override + public void onAnimationEnd(Animator animation){ + WindowManager wm=context.getSystemService(WindowManager.class); + wm.removeView(windowView); + } + }); + anim.start(); + } + + private static class AnimatableOutlineProvider extends ViewOutlineProvider{ + private float fraction=1f; + private final View view; + + private AnimatableOutlineProvider(View view){ + this.view=view; + } + + @Override + public void getOutline(View view, Outline outline){ + outline.setRoundRect(0, Math.round(view.getHeight()*(1f-fraction)), view.getWidth(), view.getHeight(), V.dp(4)); + } + + @Keep + public float getFraction(){ + return fraction; + } + + @Keep + public void setFraction(float fraction){ + this.fraction=fraction; + view.invalidateOutline(); + } + } + + public static class Builder{ + private final Context context; + private String text; + private String action; + private Runnable onActionClick; + private int bottomOffset; + + public Builder(Context context){ + this.context=context; + } + + public Builder setText(String text){ + this.text=text; + return this; + } + + public Builder setText(@StringRes int res){ + text=context.getString(res); + return this; + } + + public Builder setAction(String action, Runnable onActionClick){ + this.action=action; + this.onActionClick=onActionClick; + return this; + } + + public Builder setAction(@StringRes int action, Runnable onActionClick){ + this.action=context.getString(action); + this.onActionClick=onActionClick; + return this; + } + + public Builder setBottomOffset(int offset){ + bottomOffset=offset; + return this; + } + + public Snackbar create(){ + return new Snackbar(context, text, action, onActionClick, bottomOffset); + } + + public void show(){ + create().show(); + } + } +} diff --git a/mastodon/src/main/java/org/joinmastodon/android/ui/displayitems/ExtendedFooterStatusDisplayItem.java b/mastodon/src/main/java/org/joinmastodon/android/ui/displayitems/ExtendedFooterStatusDisplayItem.java index 417ce148c..6dd1db607 100644 --- a/mastodon/src/main/java/org/joinmastodon/android/ui/displayitems/ExtendedFooterStatusDisplayItem.java +++ b/mastodon/src/main/java/org/joinmastodon/android/ui/displayitems/ExtendedFooterStatusDisplayItem.java @@ -13,14 +13,17 @@ import android.text.style.TypefaceSpan; import android.view.View; import android.view.ViewGroup; import android.widget.TextView; +import android.widget.Toast; import org.joinmastodon.android.R; import org.joinmastodon.android.fragments.BaseStatusListFragment; import org.joinmastodon.android.fragments.StatusEditHistoryFragment; +import org.joinmastodon.android.fragments.ThreadFragment; import org.joinmastodon.android.fragments.account_list.StatusFavoritesListFragment; import org.joinmastodon.android.fragments.account_list.StatusReblogsListFragment; import org.joinmastodon.android.fragments.account_list.StatusRelatedAccountListFragment; import org.joinmastodon.android.model.Status; +import org.joinmastodon.android.ui.Snackbar; import org.joinmastodon.android.ui.utils.UiUtils; import org.parceler.Parcels; @@ -34,11 +37,13 @@ import java.util.Locale; import androidx.annotation.PluralsRes; import androidx.annotation.StringRes; import me.grishka.appkit.Nav; +import me.grishka.appkit.utils.V; public class ExtendedFooterStatusDisplayItem extends StatusDisplayItem{ public final Status status; private static final DateTimeFormatter TIME_FORMATTER=DateTimeFormatter.ofLocalizedTime(FormatStyle.SHORT); + private static final DateTimeFormatter TIME_FORMATTER_LONG=DateTimeFormatter.ofLocalizedTime(FormatStyle.MEDIUM); private static final DateTimeFormatter DATE_FORMATTER=DateTimeFormatter.ofLocalizedDate(FormatStyle.MEDIUM); public ExtendedFooterStatusDisplayItem(String parentID, BaseStatusListFragment parentFragment, Status status){ @@ -160,7 +165,14 @@ public class ExtendedFooterStatusDisplayItem extends StatusDisplayItem{ } private void showTimeSnackbar(){ - + int bottomOffset=0; + if(item.parentFragment instanceof ThreadFragment tf){ + bottomOffset=tf.getSnackbarOffset(); + } + new Snackbar.Builder(itemView.getContext()) + .setText(itemView.getContext().getString(R.string.posted_at, TIME_FORMATTER_LONG.format(item.status.createdAt.atZone(ZoneId.systemDefault())))) + .setBottomOffset(bottomOffset) + .show(); } } } diff --git a/mastodon/src/main/res/drawable/bg_rect_ripple.xml b/mastodon/src/main/res/drawable/bg_rect_ripple.xml new file mode 100644 index 000000000..a6322aabf --- /dev/null +++ b/mastodon/src/main/res/drawable/bg_rect_ripple.xml @@ -0,0 +1,8 @@ + + + + + + + + \ No newline at end of file diff --git a/mastodon/src/main/res/layout/fragment_thread.xml b/mastodon/src/main/res/layout/fragment_thread.xml new file mode 100644 index 000000000..0d817d825 --- /dev/null +++ b/mastodon/src/main/res/layout/fragment_thread.xml @@ -0,0 +1,81 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/mastodon/src/main/res/values/attrs.xml b/mastodon/src/main/res/values/attrs.xml index 74b745083..6153f9ab9 100644 --- a/mastodon/src/main/res/values/attrs.xml +++ b/mastodon/src/main/res/values/attrs.xml @@ -15,7 +15,9 @@ + + diff --git a/mastodon/src/main/res/values/strings.xml b/mastodon/src/main/res/values/strings.xml index 6d77ae5e7..1f8859468 100644 --- a/mastodon/src/main/res/values/strings.xml +++ b/mastodon/src/main/res/values/strings.xml @@ -656,4 +656,6 @@ Manage list members No members yet Find users to add + Reply to %s + Posted at %s \ No newline at end of file diff --git a/mastodon/src/main/res/values/styles.xml b/mastodon/src/main/res/values/styles.xml index b48628e0c..fb39928cb 100644 --- a/mastodon/src/main/res/values/styles.xml +++ b/mastodon/src/main/res/values/styles.xml @@ -53,6 +53,8 @@ #F9DEDC #410E0B @color/m3_sys_dark_primary + @color/m3_sys_dark_surface + @color/m3_sys_dark_on_surface #FFF #8b5000 #ab332a @@ -120,6 +122,8 @@ #8C1D18 #F9DEDC @color/m3_sys_light_primary + @color/m3_sys_light_surface + @color/m3_sys_light_on_surface #000 #ffb871 #ffb4aa