Domain badges & info sheet & my fanciest animation yet

This commit is contained in:
Grishka
2024-02-13 07:31:42 +03:00
parent efb8cd565b
commit 8dffbff97c
12 changed files with 670 additions and 71 deletions

View File

@@ -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);

View File

@@ -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;

View File

@@ -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();
}
}

View File

@@ -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;
}
}

View File

@@ -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);

View File

@@ -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<layout.getLineCount();i++){
int baseline=layout.getLineBaseline(i);
for(int offset=layout.getLineStart(i); offset<layout.getLineEnd(i); offset++){
float x=layout.getPrimaryHorizontal(offset);
CharacterAnimationState state=charStates[offset];
if(state==null || state.scaleAnimation==null){
animationPaint.setColor(getCurrentTextColor());
animationPaint.clearShadowLayer();
canvas.drawText(text, offset, offset+1, x, baseline, animationPaint);
}else{
animationPaint.setColor((int)colorEvaluator.evaluate(Math.max(0, Math.min(1, state.color.getValue())), getCurrentTextColor(), getLinkTextColors().getDefaultColor()));
float scale=state.scale.getValue();
int shadowAlpha=Math.round(255*Math.max(0, Math.min(1, state.shadowAlpha.getValue())));
animationPaint.setShadowLayer(dp(4), 0, dp(3), (getPaint().linkColor & 0xFFFFFF) | (shadowAlpha << 24));
canvas.save();
canvas.scale(scale, scale, x, baseline);
canvas.drawText(text, offset, offset+1, x, baseline, animationPaint);
canvas.restore();
}
}
}
invalidate();
}
public void animate(int startIndex, int endIndex){
for(int i=startIndex;i<endIndex;i++){
CharacterAnimationState _state=charStates[i];
if(_state==null){
_state=charStates[i]=new CharacterAnimationState();
}
CharacterAnimationState state=_state;
int finalI=i;
postOnAnimationDelayed(()->{
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--);
}
}
}