From 8dffbff97c2dd5fd9110733bc1b09437ca5151af Mon Sep 17 00:00:00 2001 From: Grishka Date: Tue, 13 Feb 2024 07:31:42 +0300 Subject: [PATCH] Domain badges & info sheet & my fanciest animation yet --- .../android/fragments/ProfileFragment.java | 24 ++- .../org/joinmastodon/android/ui/Snackbar.java | 2 +- .../DecentralizationExplainerSheet.java | 101 ++++++++++ ...anThatDoesNotBreakShitForNoGoodReason.java | 67 +++++++ .../android/ui/utils/UiUtils.java | 6 +- .../ui/views/RippleAnimationTextView.java | 170 +++++++++++++++++ .../src/main/res/drawable/bg_handle_help.xml | 17 ++ .../src/main/res/drawable/ic_badge_24px.xml | 9 + .../src/main/res/drawable/ic_public_24px.xml | 9 + .../src/main/res/layout/fragment_profile.xml | 145 ++++++++------ .../layout/sheet_decentralization_info.xml | 178 ++++++++++++++++++ mastodon/src/main/res/values/strings.xml | 13 ++ 12 files changed, 670 insertions(+), 71 deletions(-) create mode 100644 mastodon/src/main/java/org/joinmastodon/android/ui/sheets/DecentralizationExplainerSheet.java create mode 100644 mastodon/src/main/java/org/joinmastodon/android/ui/text/ImageSpanThatDoesNotBreakShitForNoGoodReason.java create mode 100644 mastodon/src/main/java/org/joinmastodon/android/ui/views/RippleAnimationTextView.java create mode 100644 mastodon/src/main/res/drawable/bg_handle_help.xml create mode 100644 mastodon/src/main/res/drawable/ic_badge_24px.xml create mode 100644 mastodon/src/main/res/drawable/ic_public_24px.xml create mode 100644 mastodon/src/main/res/layout/sheet_decentralization_info.xml diff --git a/mastodon/src/main/java/org/joinmastodon/android/fragments/ProfileFragment.java b/mastodon/src/main/java/org/joinmastodon/android/fragments/ProfileFragment.java index 227c21117..900319fbe 100644 --- a/mastodon/src/main/java/org/joinmastodon/android/fragments/ProfileFragment.java +++ b/mastodon/src/main/java/org/joinmastodon/android/fragments/ProfileFragment.java @@ -62,10 +62,12 @@ import org.joinmastodon.android.ui.OutlineProviders; import org.joinmastodon.android.ui.SimpleViewHolder; import org.joinmastodon.android.ui.SingleImagePhotoViewerListener; import org.joinmastodon.android.ui.photoviewer.PhotoViewer; +import org.joinmastodon.android.ui.sheets.DecentralizationExplainerSheet; import org.joinmastodon.android.ui.tabs.TabLayout; import org.joinmastodon.android.ui.tabs.TabLayoutMediator; import org.joinmastodon.android.ui.text.CustomEmojiSpan; import org.joinmastodon.android.ui.text.HtmlParser; +import org.joinmastodon.android.ui.text.ImageSpanThatDoesNotBreakShitForNoGoodReason; import org.joinmastodon.android.ui.utils.SimpleTextWatcher; import org.joinmastodon.android.ui.utils.UiUtils; import org.joinmastodon.android.ui.views.CoverImageView; @@ -107,7 +109,7 @@ public class ProfileFragment extends LoaderFragment implements OnBackPressedList private ImageView avatar; private CoverImageView cover; private View avatarBorder; - private TextView name, username, bio, followersCount, followersLabel, followingCount, followingLabel; + private TextView name, username, usernameDomain, bio, followersCount, followersLabel, followingCount, followingLabel; private ProgressBarButton actionButton; private ViewPager2 pager; private NestedRecyclerScrollView scrollView; @@ -185,6 +187,7 @@ public class ProfileFragment extends LoaderFragment implements OnBackPressedList avatarBorder=content.findViewById(R.id.avatar_border); name=content.findViewById(R.id.name); username=content.findViewById(R.id.username); + usernameDomain=content.findViewById(R.id.username_domain); bio=content.findViewById(R.id.bio); followersCount=content.findViewById(R.id.followers_count); followersLabel=content.findViewById(R.id.followers_label); @@ -320,6 +323,8 @@ public class ProfileFragment extends LoaderFragment implements OnBackPressedList nameEdit.addTextChangedListener(new SimpleTextWatcher(e->editDirty=true)); bioEdit.addTextChangedListener(new SimpleTextWatcher(e->editDirty=true)); + usernameDomain.setOnClickListener(v->new DecentralizationExplainerSheet(getActivity(), accountID, account).show()); + return sizeWrapper; } @@ -499,22 +504,21 @@ public class ProfileFragment extends LoaderFragment implements OnBackPressedList boolean isSelf=AccountSessionManager.getInstance().isSelf(accountID, account); if(account.locked){ - ssb=new SpannableStringBuilder("@"); - ssb.append(account.acct); - if(isSelf){ - ssb.append('@'); - ssb.append(AccountSessionManager.getInstance().getAccount(accountID).domain); - } + ssb=new SpannableStringBuilder(account.username); ssb.append(" "); Drawable lock=username.getResources().getDrawable(R.drawable.ic_lock_fill1_20px, getActivity().getTheme()).mutate(); lock.setBounds(0, 0, lock.getIntrinsicWidth(), lock.getIntrinsicHeight()); lock.setTint(username.getCurrentTextColor()); - ssb.append(getString(R.string.manually_approves_followers), new ImageSpan(lock, ImageSpan.ALIGN_BOTTOM), 0); + ssb.append(getString(R.string.manually_approves_followers), new ImageSpanThatDoesNotBreakShitForNoGoodReason(lock, ImageSpan.ALIGN_BOTTOM), 0); username.setText(ssb); }else{ - // noinspection SetTextI18n - username.setText('@'+account.acct+(isSelf ? ('@'+AccountSessionManager.getInstance().getAccount(accountID).domain) : "")); + username.setText(account.username); } + String domain=account.getDomain(); + if(TextUtils.isEmpty(domain)) + domain=AccountSessionManager.get(accountID).domain; + usernameDomain.setText(domain); + CharSequence parsedBio=HtmlParser.parse(account.note, account.emojis, Collections.emptyList(), Collections.emptyList(), accountID, account); if(TextUtils.isEmpty(parsedBio)){ bio.setVisibility(View.GONE); diff --git a/mastodon/src/main/java/org/joinmastodon/android/ui/Snackbar.java b/mastodon/src/main/java/org/joinmastodon/android/ui/Snackbar.java index 7ee499756..bee863ed2 100644 --- a/mastodon/src/main/java/org/joinmastodon/android/ui/Snackbar.java +++ b/mastodon/src/main/java/org/joinmastodon/android/ui/Snackbar.java @@ -90,7 +90,7 @@ public class Snackbar{ 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); + WindowManager.LayoutParams lp=new WindowManager.LayoutParams(WindowManager.LayoutParams.LAST_APPLICATION_WINDOW, 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; diff --git a/mastodon/src/main/java/org/joinmastodon/android/ui/sheets/DecentralizationExplainerSheet.java b/mastodon/src/main/java/org/joinmastodon/android/ui/sheets/DecentralizationExplainerSheet.java new file mode 100644 index 000000000..c15f201b1 --- /dev/null +++ b/mastodon/src/main/java/org/joinmastodon/android/ui/sheets/DecentralizationExplainerSheet.java @@ -0,0 +1,101 @@ +package org.joinmastodon.android.ui.sheets; + +import android.content.ClipData; +import android.content.ClipboardManager; +import android.content.Context; +import android.graphics.drawable.ColorDrawable; +import android.text.SpannableStringBuilder; +import android.text.Spanned; +import android.text.TextUtils; +import android.view.LayoutInflater; +import android.view.View; +import android.widget.TextView; + +import org.joinmastodon.android.R; +import org.joinmastodon.android.api.session.AccountSessionManager; +import org.joinmastodon.android.model.Account; +import org.joinmastodon.android.ui.M3AlertDialogBuilder; +import org.joinmastodon.android.ui.Snackbar; +import org.joinmastodon.android.ui.text.LinkSpan; +import org.joinmastodon.android.ui.utils.UiUtils; +import org.joinmastodon.android.ui.views.RippleAnimationTextView; +import org.jsoup.Jsoup; +import org.jsoup.nodes.Element; +import org.jsoup.nodes.Node; +import org.jsoup.nodes.TextNode; +import org.jsoup.select.NodeVisitor; + +import androidx.annotation.NonNull; +import me.grishka.appkit.views.BottomSheet; + +public class DecentralizationExplainerSheet extends BottomSheet{ + private final String handleStr; + + public DecentralizationExplainerSheet(@NonNull Context context, String accountID, Account account){ + super(context); + View content=context.getSystemService(LayoutInflater.class).inflate(R.layout.sheet_decentralization_info, null); + setContentView(content); + setNavigationBarBackground(new ColorDrawable(UiUtils.alphaBlendColors(UiUtils.getThemeColor(context, R.attr.colorM3Surface), + UiUtils.getThemeColor(context, R.attr.colorM3Primary), 0.05f)), !UiUtils.isDarkTheme()); + + TextView handleTitle=findViewById(R.id.handle_title); + RippleAnimationTextView handle=findViewById(R.id.handle); + TextView usernameExplanation=findViewById(R.id.username_text); + TextView serverExplanation=findViewById(R.id.server_text); + TextView handleExplanation=findViewById(R.id.handle_explanation); + findViewById(R.id.btn_cancel).setOnClickListener(v->dismiss()); + + String domain=account.getDomain(); + if(TextUtils.isEmpty(domain)) + domain=AccountSessionManager.get(accountID).domain; + handleStr="@"+account.username+"@"+domain; + boolean isSelf=AccountSessionManager.getInstance().isSelf(accountID, account); + + handleTitle.setText(isSelf ? R.string.handle_title_own : R.string.handle_title); + handle.setText(handleStr); + usernameExplanation.setText(isSelf ? R.string.handle_username_explanation_own : R.string.handle_username_explanation); + serverExplanation.setText(isSelf ? R.string.handle_server_explanation_own : R.string.handle_server_explanation); + + String explanation=context.getString(isSelf ? R.string.handle_explanation_own : R.string.handle_explanation); + SpannableStringBuilder ssb=new SpannableStringBuilder(); + Jsoup.parseBodyFragment(explanation).body().traverse(new NodeVisitor(){ + private int spanStart; + @Override + public void head(Node node, int depth){ + if(node instanceof TextNode tn){ + ssb.append(tn.text()); + }else if(node instanceof Element){ + spanStart=ssb.length(); + } + } + + @Override + public void tail(Node node, int depth){ + if(node instanceof Element){ + ssb.setSpan(new LinkSpan("", DecentralizationExplainerSheet.this::showActivityPubAlert, LinkSpan.Type.CUSTOM, null, null, null), spanStart, ssb.length(), Spanned.SPAN_EXCLUSIVE_EXCLUSIVE); + } + } + }); + handleExplanation.setText(ssb); + + findViewById(R.id.handle_wrap).setOnClickListener(v->{ + context.getSystemService(ClipboardManager.class).setPrimaryClip(ClipData.newPlainText(null, handleStr)); + if(UiUtils.needShowClipboardToast()){ + new Snackbar.Builder(context) + .setText(R.string.handle_copied) + .show(); + } + }); + String _domain=domain; + findViewById(R.id.username_row).setOnClickListener(v->handle.animate(1, account.username.length()+1)); + findViewById(R.id.server_row).setOnClickListener(v->handle.animate(handleStr.length()-_domain.length(), handleStr.length())); + } + + private void showActivityPubAlert(LinkSpan s){ + new M3AlertDialogBuilder(getContext()) + .setTitle(R.string.what_is_activitypub_title) + .setMessage(R.string.what_is_activitypub) + .setPositiveButton(R.string.ok, null) + .show(); + } +} diff --git a/mastodon/src/main/java/org/joinmastodon/android/ui/text/ImageSpanThatDoesNotBreakShitForNoGoodReason.java b/mastodon/src/main/java/org/joinmastodon/android/ui/text/ImageSpanThatDoesNotBreakShitForNoGoodReason.java new file mode 100644 index 000000000..8611665f4 --- /dev/null +++ b/mastodon/src/main/java/org/joinmastodon/android/ui/text/ImageSpanThatDoesNotBreakShitForNoGoodReason.java @@ -0,0 +1,67 @@ +package org.joinmastodon.android.ui.text; + +import android.content.Context; +import android.graphics.Bitmap; +import android.graphics.Paint; +import android.graphics.drawable.Drawable; +import android.net.Uri; +import android.text.style.ImageSpan; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; + +public class ImageSpanThatDoesNotBreakShitForNoGoodReason extends ImageSpan{ + public ImageSpanThatDoesNotBreakShitForNoGoodReason(@NonNull Bitmap b){ + super(b); + } + + public ImageSpanThatDoesNotBreakShitForNoGoodReason(@NonNull Bitmap b, int verticalAlignment){ + super(b, verticalAlignment); + } + + public ImageSpanThatDoesNotBreakShitForNoGoodReason(@NonNull Context context, @NonNull Bitmap bitmap){ + super(context, bitmap); + } + + public ImageSpanThatDoesNotBreakShitForNoGoodReason(@NonNull Context context, @NonNull Bitmap bitmap, int verticalAlignment){ + super(context, bitmap, verticalAlignment); + } + + public ImageSpanThatDoesNotBreakShitForNoGoodReason(@NonNull Drawable drawable){ + super(drawable); + } + + public ImageSpanThatDoesNotBreakShitForNoGoodReason(@NonNull Drawable drawable, int verticalAlignment){ + super(drawable, verticalAlignment); + } + + public ImageSpanThatDoesNotBreakShitForNoGoodReason(@NonNull Drawable drawable, @NonNull String source){ + super(drawable, source); + } + + public ImageSpanThatDoesNotBreakShitForNoGoodReason(@NonNull Drawable drawable, @NonNull String source, int verticalAlignment){ + super(drawable, source, verticalAlignment); + } + + public ImageSpanThatDoesNotBreakShitForNoGoodReason(@NonNull Context context, @NonNull Uri uri){ + super(context, uri); + } + + public ImageSpanThatDoesNotBreakShitForNoGoodReason(@NonNull Context context, @NonNull Uri uri, int verticalAlignment){ + super(context, uri, verticalAlignment); + } + + public ImageSpanThatDoesNotBreakShitForNoGoodReason(@NonNull Context context, int resourceId){ + super(context, resourceId); + } + + public ImageSpanThatDoesNotBreakShitForNoGoodReason(@NonNull Context context, int resourceId, int verticalAlignment){ + super(context, resourceId, verticalAlignment); + } + + @Override + public int getSize(@NonNull Paint paint, CharSequence text, int start, int end, @Nullable Paint.FontMetricsInt fm){ + // Purposefully not touching the font metrics + return getDrawable().getBounds().right; + } +} diff --git a/mastodon/src/main/java/org/joinmastodon/android/ui/utils/UiUtils.java b/mastodon/src/main/java/org/joinmastodon/android/ui/utils/UiUtils.java index c94755cf8..e0c6d3438 100644 --- a/mastodon/src/main/java/org/joinmastodon/android/ui/utils/UiUtils.java +++ b/mastodon/src/main/java/org/joinmastodon/android/ui/utils/UiUtils.java @@ -929,11 +929,15 @@ public class UiUtils{ public static void maybeShowTextCopiedToast(Context context){ //show toast, android from S_V2 on has built-in popup, as documented in //https://developer.android.com/develop/ui/views/touch-and-input/copy-paste#duplicate-notifications - if(Build.VERSION.SDK_INT<=Build.VERSION_CODES.S_V2){ + if(needShowClipboardToast()){ Toast.makeText(context, R.string.text_copied, Toast.LENGTH_SHORT).show(); } } + public static boolean needShowClipboardToast(){ + return Build.VERSION.SDK_INT<=Build.VERSION_CODES.S_V2; + } + public static void setAllPaddings(View view, int paddingDp){ int pad=V.dp(paddingDp); view.setPadding(pad, pad, pad, pad); diff --git a/mastodon/src/main/java/org/joinmastodon/android/ui/views/RippleAnimationTextView.java b/mastodon/src/main/java/org/joinmastodon/android/ui/views/RippleAnimationTextView.java new file mode 100644 index 000000000..b44db321a --- /dev/null +++ b/mastodon/src/main/java/org/joinmastodon/android/ui/views/RippleAnimationTextView.java @@ -0,0 +1,170 @@ +package org.joinmastodon.android.ui.views; + +import android.animation.ArgbEvaluator; +import android.content.Context; +import android.graphics.Canvas; +import android.graphics.Paint; +import android.text.Layout; +import android.util.AttributeSet; +import android.widget.TextView; + +import androidx.dynamicanimation.animation.FloatValueHolder; +import androidx.dynamicanimation.animation.SpringAnimation; +import androidx.dynamicanimation.animation.SpringForce; +import me.grishka.appkit.utils.CustomViewHelper; + +public class RippleAnimationTextView extends TextView implements CustomViewHelper{ + private final Paint animationPaint=new Paint(Paint.ANTI_ALIAS_FLAG); + private CharacterAnimationState[] charStates; + private final ArgbEvaluator colorEvaluator=new ArgbEvaluator(); + private int runningAnimCount=0; + private Runnable[] delayedAnimations1, delayedAnimations2; + + public RippleAnimationTextView(Context context){ + this(context, null); + } + + public RippleAnimationTextView(Context context, AttributeSet attrs){ + this(context, attrs, 0); + } + + public RippleAnimationTextView(Context context, AttributeSet attrs, int defStyle){ + super(context, attrs, defStyle); + } + + @Override + protected void onTextChanged(CharSequence text, int start, int lengthBefore, int lengthAfter){ + super.onTextChanged(text, start, lengthBefore, lengthAfter); + if(charStates!=null){ + for(CharacterAnimationState state:charStates){ + state.colorAnimation.cancel(); + state.shadowAnimation.cancel(); + state.scaleAnimation.cancel(); + } + for(Runnable r:delayedAnimations1){ + if(r!=null) + removeCallbacks(r); + } + for(Runnable r:delayedAnimations2){ + if(r!=null) + removeCallbacks(r); + } + } + charStates=new CharacterAnimationState[lengthAfter]; + delayedAnimations1=new Runnable[lengthAfter]; + delayedAnimations2=new Runnable[lengthAfter]; + } + + @Override + protected void onDraw(Canvas canvas){ + if(runningAnimCount==0 && !areThereDelayedAnimations()){ + super.onDraw(canvas); + return; + } + Layout layout=getLayout(); + animationPaint.set(getPaint()); + CharSequence text=layout.getText(); + for(int i=0;i{ + if(!state.colorAnimation.isRunning()) + runningAnimCount++; + state.colorAnimation.animateToFinalPosition(1f); + if(!state.shadowAnimation.isRunning()) + runningAnimCount++; + state.shadowAnimation.animateToFinalPosition(0.3f); + if(!state.scaleAnimation.isRunning()) + runningAnimCount++; + state.scaleAnimation.animateToFinalPosition(1.2f); + invalidate(); + + if(delayedAnimations1[finalI]!=null) + removeCallbacks(delayedAnimations1[finalI]); + if(delayedAnimations2[finalI]!=null) + removeCallbacks(delayedAnimations2[finalI]); + Runnable delay1=()->{ + if(!state.colorAnimation.isRunning()) + runningAnimCount++; + state.colorAnimation.animateToFinalPosition(0f); + if(!state.shadowAnimation.isRunning()) + runningAnimCount++; + state.shadowAnimation.animateToFinalPosition(0f); + invalidate(); + delayedAnimations1[finalI]=null; + }; + Runnable delay2=()->{ + if(!state.scaleAnimation.isRunning()) + runningAnimCount++; + state.scaleAnimation.animateToFinalPosition(1f); + delayedAnimations2[finalI]=null; + }; + delayedAnimations1[finalI]=delay1; + delayedAnimations2[finalI]=delay2; + postOnAnimationDelayed(delay1, 2000); + postOnAnimationDelayed(delay2, 100); + }, 20L*(i-startIndex)); + } + } + + private boolean areThereDelayedAnimations(){ + for(Runnable r:delayedAnimations1){ + if(r!=null) + return true; + } + for(Runnable r:delayedAnimations2){ + if(r!=null) + return true; + } + return false; + } + + private class CharacterAnimationState extends FloatValueHolder{ + private final SpringAnimation scaleAnimation, colorAnimation, shadowAnimation; + private final FloatValueHolder scale=new FloatValueHolder(1), color=new FloatValueHolder(), shadowAlpha=new FloatValueHolder(); + + public CharacterAnimationState(){ + scaleAnimation=new SpringAnimation(scale); + colorAnimation=new SpringAnimation(color); + shadowAnimation=new SpringAnimation(shadowAlpha); + setupSpring(scaleAnimation); + setupSpring(colorAnimation); + setupSpring(shadowAnimation); + } + + private void setupSpring(SpringAnimation anim){ + anim.setMinimumVisibleChange(0.01f); + anim.setSpring(new SpringForce().setStiffness(500f).setDampingRatio(0.175f)); + anim.addEndListener((animation, canceled, value, velocity)->runningAnimCount--); + } + } +} diff --git a/mastodon/src/main/res/drawable/bg_handle_help.xml b/mastodon/src/main/res/drawable/bg_handle_help.xml new file mode 100644 index 000000000..ebfec2f2a --- /dev/null +++ b/mastodon/src/main/res/drawable/bg_handle_help.xml @@ -0,0 +1,17 @@ + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/mastodon/src/main/res/drawable/ic_badge_24px.xml b/mastodon/src/main/res/drawable/ic_badge_24px.xml new file mode 100644 index 000000000..489eff0b5 --- /dev/null +++ b/mastodon/src/main/res/drawable/ic_badge_24px.xml @@ -0,0 +1,9 @@ + + + diff --git a/mastodon/src/main/res/drawable/ic_public_24px.xml b/mastodon/src/main/res/drawable/ic_public_24px.xml new file mode 100644 index 000000000..df153eb2f --- /dev/null +++ b/mastodon/src/main/res/drawable/ic_public_24px.xml @@ -0,0 +1,9 @@ + + + diff --git a/mastodon/src/main/res/layout/fragment_profile.xml b/mastodon/src/main/res/layout/fragment_profile.xml index b2da7e64d..c72f56259 100644 --- a/mastodon/src/main/res/layout/fragment_profile.xml +++ b/mastodon/src/main/res/layout/fragment_profile.xml @@ -61,7 +61,7 @@ android:layout_below="@id/cover" android:layout_alignParentStart="true" android:layout_marginStart="12dp" - android:layout_marginTop="-44dp" + android:layout_marginTop="-36dp" android:background="@drawable/profile_ava_bg" android:outlineProvider="@null"> @@ -76,64 +76,63 @@ - - + android:layout_toEndOf="@id/avatar_border" + android:layout_marginTop="14dp" + android:layout_marginStart="12dp" + android:layout_marginEnd="16dp" + android:fontFamily="sans-serif" + android:textAlignment="viewStart" + android:textAppearance="@style/m3_title_large" + android:textColor="?colorM3OnSurface" + android:maxLines="2" + android:ellipsize="end" + tools:text="Eugen" /> - + - - + android:layout_height="20dp" + android:textAppearance="@style/m3_body_medium" + android:textColor="?colorM3OnSurfaceVariant" + android:singleLine="true" + android:ellipsize="end" + android:gravity="center_vertical" + tools:text="Gargron" /> + + + + - - - - - - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +