Merge upstream redesign (#714)

* merge toolbar fragment

* Fix store screenshot generator

* Fix alert color

* Fix #609

* Fix crash

* bigger hitbox for chips

* support mastodon languages

* merge ui utils

* merge stuff

* fix icon

* ensure 48dp touch target

* init local prefs, add helper function for enum values

* update compose action layout

* merge compose-adj files

* update extended footer

* fix poll wrong option checked

closes sk22#641

* no border when disabled

closes sk22#640

* Fix #610

* Minor fixes

* Fix alert color

* Fix #609

* Fix crash

* Fix #610

* Minor fixes

* add resources

* more compatible mastodon language

* fix html parser

* mark as read on refresh

* update tab bar

* tweak m3 buttons

* update compose-adj files

* tweak and update styles

* m3 expand button

* flag icon should be 18dp, actually

* More minor fixes

closes #612

* More minor fixes

closes #612

* Bump version

* fix no create status event when redrafting

* add material 3 assets

* New translations strings.xml (Greek)

* New translations strings.xml (Greek)

* New translations strings.xml (Italian)

* New translations strings.xml (Greek)

* New translations strings.xml (Italian)

* New translations strings.xml (Thai)

* New translations strings.xml (Thai)

* New translations strings.xml (Italian)

* New translations strings.xml (Thai)

* use new buttons for profile fragment

* merge compose fragment

* merge all the styles! oh dear

* New translations full_description.txt (Indonesian)

* New translations full_description.txt (Chinese Simplified)

* New translations strings.xml (Chinese Simplified)

* New translations full_description.txt (Chinese Simplified)

* Fix #615

* Minor fixes

* Fix #611

* A bunch of crash fixes

* New translations strings.xml (Greek)

* Make the default server configurable

* Pass the system timezone to server when signing up

* New translations strings.xml (Chinese Simplified)

* New translations strings.xml (Japanese)

* Fix #615

* Minor fixes

* Fix #611

* A bunch of crash fixes

* Make the default server configurable

* Pass the system timezone to server when signing up

* oops. accidentally pasted the commit message in the code

* Remove unused code that caused a crash for some users ¯\_(ツ)_/¯

* New translations strings.xml (Japanese)

* New translations strings.xml (Japanese)

* Remove unused code that caused a crash for some users ¯\_(ツ)_/¯

* New translations strings.xml (Polish)

* New translations strings.xml (Polish)

* New translations strings.xml (Turkish)

* New translations strings.xml (Belarusian)

* prepare merging profile fragment

* merge profile fragment

* New translations strings.xml (Belarusian)

* New translations strings.xml (Greek)

* fix icon padding

* apply post header changes

* minor margin tweaks

* fix footer buttons

* fix header announcement buttons

* New translations strings.xml (Japanese)

* New translations strings.xml (Japanese)

* New translations strings.xml (Japanese)

* New translations strings.xml (Japanese)

* New translations strings.xml (Japanese)

* New translations strings.xml (Japanese)

* New translations full_description.txt (Japanese)

* New translations strings.xml (Icelandic)

* New translations strings.xml (Icelandic)

* New translations strings.xml (Icelandic)

* fix replying

* New translations strings.xml (Icelandic)

* fix translate button

* fix more button visibility

* fix counts label styling

* fix disabled boost button opacity

* fix tab layouts

* fix notification icon color crash

* New translations strings.xml (Greek)

* implement elevation listener in home tab

* fix elevation and listener in home tab

* add elevation scroll listener to notifications

* New translations strings.xml (Scottish Gaelic)

* Add editorconfig

So that PRs like #625 don't happen again

* Crash fix

* 🤔

* New translations strings.xml (Greek)

* New translations strings.xml (Japanese)

* New translations strings.xml (French)

* New translations strings.xml (French)

* New translations strings.xml (French)

* fix notification elevation and integrate divider

* 🤔

* Crash fix

* Add editorconfig

So that PRs like #625 don't happen again

* New translations strings.xml (Turkish)

* save interactions in cache

* New translations strings.xml (Turkish)

* merge new discover/search

* New translations strings.xml (Bengali)

* New translations strings.xml (Scottish Gaelic)

* New translations strings.xml (Bengali)

* merge new settings fragments

* fix no auth callback always being executed

* allow opening server info from profile

closes sk22#593

* fix hide boosts icon color

closes sk22#676

* New translations strings.xml (Turkish)

* New translations strings.xml (Turkish)

* New translations strings.xml (Turkish)

* New translations strings.xml (Chinese Simplified)

* New translations strings.xml (Turkish)

* New translations strings.xml (Chinese Simplified)

* New translations strings.xml (German)

* New translations strings.xml (German)

* New translations strings.xml (Turkish)

* update fedinuke list

from source; doesn't contain any modifications regarding a recent issue

* New translations strings.xml (Turkish)

* remove unused class

* fix crash

* darken m3 outline color a bit

* use m3 outline again

* fix misalignment

closes sk22#682

* New translations strings.xml (Turkish)

* New translations full_description.txt (Turkish)

* New translations short_description.txt (Turkish)

* fix crash

* fix metadata sorting

* show pronouns in header/account lists

* fix broken divider line

closes sk22#679

* trim pronouns

* improve pronoun display

* New translations strings.xml (French)

* New translations strings.xml (Japanese)

* fix broken federated timeline

closes sk22#685

* fix broken -1 fallback behavior

closes sk22#681

* don't display nothing if server about request fails

closes sk22#678

* New translations strings.xml (Ukrainian)

* migrate global prefs to local prefs

* do confirm unfollow by default

* New translations strings.xml (Ukrainian)

* New translations strings.xml (Ukrainian)

* New translations full_description.txt (Ukrainian)

* New translations strings.xml (Ukrainian)

* New translations strings.xml (Ukrainian)

* New translations strings.xml (Ukrainian)

* New translations strings.xml (Ukrainian)

* New translations strings.xml (Ukrainian)

* New translations strings.xml (Russian)

* New translations strings.xml (Vietnamese)

* New translations strings.xml (Ukrainian)

* New translations strings.xml (Vietnamese)

* New translations full_description.txt (Ukrainian)

* New translations strings.xml (Ukrainian)

* New translations strings.xml (Vietnamese)

* New translations strings.xml (Ukrainian)

* New translations strings.xml (Ukrainian)

* make sure list in prefs are always mutable and nut null

* New translations strings.xml (Ukrainian)

* New translations strings.xml (Ukrainian)

* New translations strings.xml (Russian)

* fix pronouns edge case

* add back fix for stretched images

closes sk22#636

* fix null pointer on missing default posting language

* fix default posting language not being applied

* bigger username hitbox

closes sk22#688

* fix rtl header username alignment

closes sk22#689

* New translations strings.xml (Ukrainian)

* New translations strings.xml (Ukrainian)

* hopefully fix crashes

closes sk22#692

* New translations strings.xml (Ukrainian)

* New translations full_description.txt (Ukrainian)

* fix pronoun crash

* New translations strings.xml (Persian)

* New translations strings.xml (Ukrainian)

* re-add true black mode

* asterisk can be a pronoun

* New translations strings.xml (Persian)

* true black mode fixes and clean-ups

* material 3 button background for switcher

* darker tab bar selected background

* better align follow/following button widths

* restore rainbow refresh colors

* fix search transition

* fix min width issue with switcher button

* fix no elevation when true black is enabled in light theme

* use statusForContent to determine spoilerRevealed

closes sk22#694

* New translations strings.xml (Persian)

* New translations strings.xml (Persian)

* New translations strings.xml (Persian)

* New translations strings.xml (Persian)

* New translations strings.xml (Persian)

* New translations strings.xml (Persian)

* fix profile tab bar in true black theme

* fix m3 default button style

closes sk22#697

* prettier role badges

closes sk22#663

* fix translate button spacing

closes sk22#655

* use m3 switches in dialogs

closes sk22#653

* implement color palette switcher

* fix color palettes being overwritten

* add display and notification settings

* clean up code

* per-account single notification setting

* add missing items to notification types

* add prefix replies setting

* add show replies/boosts and reply visibility

* add load/see new posts settings

* fix spectator mode missing spoiler padding

* add a bunch of display settings

* update fedinuke

* add content type settings

* add settings for local-onlu

* add missing settings items

* fix visibility button icon tint

* hopefully fix some crashes

* normalize padding above edit text

* apparently, some people don't like pills

closes sk22#706

* fix play button color

closes sk22#705
This commit is contained in:
sk22
2023-07-16 18:01:42 +02:00
committed by GitHub
parent 3cfea0e660
commit 7677ad39ca
744 changed files with 24873 additions and 13485 deletions

View File

@@ -331,8 +331,8 @@ public class AccountSwitcherSheet extends BottomSheet{
onClick.accept(item.getID(), false);
return;
}
AccountSessionManager.getInstance().setLastActiveAccountID(item.getID());
if(AccountSessionManager.getInstance().tryGetAccount(item.getID())!=null)
AccountSessionManager.getInstance().setLastActiveAccountID(item.getID());
activity.finish();
activity.startActivity(new Intent(activity, MainActivity.class));
}

View File

@@ -18,15 +18,13 @@ package org.joinmastodon.android.ui;
import android.animation.Animator;
import android.animation.AnimatorListenerAdapter;
import android.animation.TimeInterpolator;
import android.animation.ValueAnimator;
import android.view.View;
import android.view.ViewPropertyAnimator;
import androidx.annotation.NonNull;
import androidx.recyclerview.widget.RecyclerView;
import androidx.recyclerview.widget.SimpleItemAnimator;
import org.joinmastodon.android.ui.displayitems.MediaGridStatusDisplayItem;
import me.grishka.appkit.utils.CubicBezierInterpolator;
import java.util.ArrayList;
@@ -360,14 +358,7 @@ public class BetterItemAnimator extends SimpleItemAnimator{
mChangeAnimations.add(changeInfo.oldHolder);
oldViewAnim.translationX(changeInfo.toX - changeInfo.fromX);
oldViewAnim.translationY(changeInfo.toY - changeInfo.fromY);
float alpha = 0;
if (holder instanceof MediaGridStatusDisplayItem.Holder mediaItemHolder) {
if (mediaItemHolder.isSizeUpdating()) {
alpha = 1; // Image will flicker out and then in if alpha is 0
mediaItemHolder.sizeUpdated();
}
}
oldViewAnim.alpha(alpha).setListener(new AnimatorListenerAdapter() {
oldViewAnim.alpha(0).setListener(new AnimatorListenerAdapter() {
@Override
public void onAnimationStart(Animator animator) {
dispatchChangeStarting(changeInfo.oldHolder, true);

View File

@@ -2,14 +2,19 @@ package org.joinmastodon.android.ui;
import android.annotation.SuppressLint;
import android.app.Activity;
import android.content.res.TypedArray;
import android.content.res.ColorStateList;
import android.graphics.Rect;
import android.graphics.drawable.Animatable;
import android.graphics.drawable.ColorDrawable;
import android.graphics.drawable.Drawable;
import android.text.TextUtils;
import android.view.Gravity;
import android.view.View;
import android.view.ViewGroup;
import android.widget.FrameLayout;
import android.widget.ImageButton;
import android.widget.ImageView;
import android.widget.LinearLayout;
import android.widget.TextView;
import com.squareup.otto.Subscribe;
@@ -22,7 +27,6 @@ import org.joinmastodon.android.model.EmojiCategory;
import org.joinmastodon.android.ui.utils.UiUtils;
import java.util.List;
import java.util.function.Consumer;
import java.util.stream.Collectors;
import androidx.annotation.NonNull;
@@ -45,9 +49,8 @@ public class CustomEmojiPopupKeyboard extends PopupKeyboard{
private ListImageLoaderWrapper imgLoader;
private MergeRecyclerAdapter adapter=new MergeRecyclerAdapter();
private String domain;
private int gridGap;
private int spanCount=6;
private Consumer<Emoji> listener;
private Listener listener;
public CustomEmojiPopupKeyboard(Activity activity, List<EmojiCategory> emojis, String domain){
super(activity);
@@ -62,11 +65,8 @@ public class CustomEmojiPopupKeyboard extends PopupKeyboard{
@Override
protected void onMeasure(int widthSpec, int heightSpec){
// it's important to do this in onMeasure so the child views will be measured with correct paddings already set
spanCount=Math.round(MeasureSpec.getSize(widthSpec)/(float)V.dp(44+20));
spanCount=Math.round((MeasureSpec.getSize(widthSpec)-V.dp(32-8))/(float)V.dp(48+8));
lm.setSpanCount(spanCount);
int pad=V.dp(16);
gridGap=(MeasureSpec.getSize(widthSpec)-pad*2-V.dp(44)*spanCount)/(spanCount-1);
setPadding(pad, 0, pad-gridGap, 0);
invalidateItemDecorations();
super.onMeasure(widthSpec, heightSpec);
}
@@ -80,6 +80,7 @@ public class CustomEmojiPopupKeyboard extends PopupKeyboard{
}
});
list.setLayoutManager(lm);
list.setPadding(V.dp(16), 0, V.dp(16), 0);
imgLoader=new ListImageLoaderWrapper(activity, list, new RecyclerViewDelegate(list), null);
for(EmojiCategory category:emojis)
@@ -88,22 +89,52 @@ public class CustomEmojiPopupKeyboard extends PopupKeyboard{
list.addItemDecoration(new RecyclerView.ItemDecoration(){
@Override
public void getItemOffsets(@NonNull Rect outRect, @NonNull View view, @NonNull RecyclerView parent, @NonNull RecyclerView.State state){
outRect.right=gridGap;
if(view instanceof TextView){ // section header
if(parent.getChildAdapterPosition(view)>0)
outRect.top=-gridGap; // negate the margin added by the emojis above
outRect.left=outRect.right=V.dp(-16);
}else{
outRect.bottom=gridGap;
EmojiViewHolder evh=(EmojiViewHolder) parent.getChildViewHolder(view);
int col=evh.positionWithinCategory%spanCount;
if(col<spanCount-1){
outRect.right=V.dp(8);
}
outRect.bottom=V.dp(8);
}
}
});
list.setBackgroundColor(UiUtils.getThemeColor(activity, android.R.attr.colorBackground));
list.setSelector(null);
list.setClipToPadding(false);
new StickyHeadersOverlay(activity, 0).install(list);
return list;
LinearLayout ll=new LinearLayout(activity);
ll.setOrientation(LinearLayout.VERTICAL);
ll.setElevation(V.dp(3));
ll.setBackgroundResource(R.drawable.bg_m3_surface1);
ll.addView(list, new LinearLayout.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, 0, 1f));
FrameLayout bottomPanel=new FrameLayout(activity);
bottomPanel.setPadding(V.dp(16), V.dp(8), V.dp(16), V.dp(8));
bottomPanel.setBackgroundResource(R.drawable.bg_m3_surface2);
ll.addView(bottomPanel, new LinearLayout.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.WRAP_CONTENT));
ImageButton hideKeyboard=new ImageButton(activity);
hideKeyboard.setImageResource(R.drawable.ic_fluent_keyboard_dock_24_regular);
hideKeyboard.setImageTintList(ColorStateList.valueOf(UiUtils.getThemeColor(activity, R.attr.colorM3OnSurfaceVariant)));
hideKeyboard.setBackgroundResource(R.drawable.bg_round_ripple);
hideKeyboard.setOnClickListener(v->hide());
bottomPanel.addView(hideKeyboard, new FrameLayout.LayoutParams(V.dp(36), V.dp(36), Gravity.LEFT));
ImageButton backspace=new ImageButton(activity);
backspace.setImageResource(R.drawable.ic_fluent_backspace_24_regular);
backspace.setImageTintList(ColorStateList.valueOf(UiUtils.getThemeColor(activity, R.attr.colorM3OnSurfaceVariant)));
backspace.setBackgroundResource(R.drawable.bg_round_ripple);
backspace.setOnClickListener(v->listener.onBackspace());
bottomPanel.addView(backspace, new FrameLayout.LayoutParams(V.dp(36), V.dp(36), Gravity.RIGHT));
return ll;
}
public void setListener(Consumer<Emoji> listener){
public void setListener(Listener listener){
this.listener=listener;
}
@@ -123,7 +154,7 @@ public class CustomEmojiPopupKeyboard extends PopupKeyboard{
public SingleCategoryAdapter(EmojiCategory category){
super(imgLoader);
this.category=category;
requests=category.emojis.stream().map(e->new UrlImageLoaderRequest(e.url, V.dp(44), V.dp(44))).collect(Collectors.toList());
requests=category.emojis.stream().map(e->new UrlImageLoaderRequest(e.url, V.dp(24), V.dp(24))).collect(Collectors.toList());
}
@NonNull
@@ -134,11 +165,11 @@ public class CustomEmojiPopupKeyboard extends PopupKeyboard{
@Override
public void onBindViewHolder(RecyclerView.ViewHolder holder, int position){
if(holder instanceof EmojiViewHolder){
((EmojiViewHolder) holder).bind(category.emojis.get(position-1));
((EmojiViewHolder) holder).positionWithinCategory=position-1;
}else if(holder instanceof SectionHeaderViewHolder){
((SectionHeaderViewHolder) holder).bind(TextUtils.isEmpty(category.title) ? domain : category.title);
if(holder instanceof EmojiViewHolder evh){
evh.bind(category.emojis.get(position-1));
evh.positionWithinCategory=position-1;
}else if(holder instanceof SectionHeaderViewHolder shvh){
shvh.bind(TextUtils.isEmpty(category.title) ? domain : category.title);
}
super.onBindViewHolder(holder, position);
}
@@ -164,14 +195,24 @@ public class CustomEmojiPopupKeyboard extends PopupKeyboard{
}
}
private class SectionHeaderViewHolder extends BindableViewHolder<String>{
private class SectionHeaderViewHolder extends BindableViewHolder<String> implements StickyHeadersOverlay.HeaderViewHolder{
private Drawable background;
public SectionHeaderViewHolder(){
super(activity, R.layout.item_emoji_section, list);
background=new ColorDrawable(UiUtils.alphaBlendThemeColors(activity, R.attr.colorM3Surface, R.attr.colorM3Primary, .08f));
itemView.setBackground(background);
}
@Override
public void onBind(String item){
((TextView)itemView).setText(item);
setStickyFactor(0);
}
@Override
public void setStickyFactor(float factor){
background.setAlpha(Math.round(255*factor));
}
}
@@ -180,8 +221,11 @@ public class CustomEmojiPopupKeyboard extends PopupKeyboard{
public EmojiViewHolder(){
super(new ImageView(activity));
ImageView img=(ImageView) itemView;
img.setLayoutParams(new RecyclerView.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, V.dp(44)));
img.setLayoutParams(new RecyclerView.LayoutParams(V.dp(48), V.dp(48)));
img.setScaleType(ImageView.ScaleType.FIT_CENTER);
int pad=V.dp(12);
img.setPadding(pad, pad, pad, pad);
img.setBackgroundResource(R.drawable.bg_custom_emoji);
}
@Override
@@ -203,7 +247,12 @@ public class CustomEmojiPopupKeyboard extends PopupKeyboard{
@Override
public void onClick(){
listener.accept(item);
listener.onEmojiSelected(item);
}
}
public interface Listener{
void onEmojiSelected(Emoji emoji);
void onBackspace();
}
}

View File

@@ -67,7 +67,7 @@ public class ImageDescriptionSheet extends BottomSheet{
list.setClipToPadding(false);
setContentView(list);
setNavigationBarBackground(new ColorDrawable(UiUtils.getThemeColor(activity, R.attr.colorWindowBackground)), !UiUtils.isDarkTheme());
setNavigationBarBackground(new ColorDrawable(UiUtils.getThemeColor(activity, R.attr.colorM3Surface)), !UiUtils.isDarkTheme());
}
@Override

View File

@@ -2,13 +2,21 @@ package org.joinmastodon.android.ui;
import android.app.AlertDialog;
import android.content.Context;
import android.text.TextUtils;
import android.view.LayoutInflater;
import android.view.View;
import android.widget.Button;
import android.widget.ImageView;
import android.widget.TextView;
import org.joinmastodon.android.R;
import androidx.annotation.StringRes;
import me.grishka.appkit.utils.V;
public class M3AlertDialogBuilder extends AlertDialog.Builder{
private CharSequence supportingText, title, helpText;
private AlertDialog alert;
public M3AlertDialogBuilder(Context context){
super(context);
}
@@ -19,12 +27,36 @@ public class M3AlertDialogBuilder extends AlertDialog.Builder{
@Override
public AlertDialog create(){
AlertDialog alert=super.create();
if(!TextUtils.isEmpty(helpText) && !TextUtils.isEmpty(supportingText))
throw new IllegalStateException("You can't have both help text and supporting text in the same alert");
if(!TextUtils.isEmpty(supportingText)){
View titleLayout=getContext().getSystemService(LayoutInflater.class).inflate(R.layout.alert_title_with_supporting_text, null);
TextView title=titleLayout.findViewById(R.id.title);
TextView subtitle=titleLayout.findViewById(R.id.subtitle);
title.setText(this.title);
subtitle.setText(supportingText);
setCustomTitle(titleLayout);
}else if(!TextUtils.isEmpty(helpText)){
View titleLayout=getContext().getSystemService(LayoutInflater.class).inflate(R.layout.alert_title_with_help, null);
TextView title=titleLayout.findViewById(R.id.title);
TextView helpText=titleLayout.findViewById(R.id.help_text);
View helpButton=titleLayout.findViewById(R.id.help);
title.setText(this.title);
helpText.setText(this.helpText);
helpButton.setOnClickListener(v->{
helpText.setVisibility(helpText.getVisibility()==View.VISIBLE ? View.GONE : View.VISIBLE);
helpButton.setSelected(helpText.getVisibility()==View.VISIBLE);
});
setCustomTitle(titleLayout);
}
alert=super.create();
alert.create();
Button btn=alert.getButton(AlertDialog.BUTTON_POSITIVE);
if(btn!=null){
View buttonBar=(View) btn.getParent();
buttonBar.setPadding(V.dp(16), 0, V.dp(16), V.dp(24));
buttonBar.setPadding(V.dp(16), V.dp(16), V.dp(16), V.dp(16));
((View)buttonBar.getParent()).setPadding(0, 0, 0, 0);
}
// hacc
@@ -32,16 +64,8 @@ public class M3AlertDialogBuilder extends AlertDialog.Builder{
if(titleID!=0){
View title=alert.findViewById(titleID);
if(title!=null){
int iconID=getContext().getResources().getIdentifier("icon", "id", "android");
int alertTitleID=getContext().getResources().getIdentifier("alertTitle", "id", "android");
if (alertTitleID != 0 && iconID != 0) {
ImageView icon = title.findViewById(iconID);
if (icon.getDrawable() != null) {
title.findViewById(alertTitleID).setPadding(V.dp(8), 0, 0, 0);
}
}
int pad=V.dp(24);
title.setPadding(pad, pad, pad, V.dp(12));
title.setPadding(pad, pad, pad, pad);
}
}
int titleDividerID=getContext().getResources().getIdentifier("titleDividerNoCustom", "id", "android");
@@ -58,13 +82,40 @@ public class M3AlertDialogBuilder extends AlertDialog.Builder{
scrollView.setPadding(0, 0, 0, 0);
}
}
int messageID=getContext().getResources().getIdentifier("message", "id", "android");
if(messageID!=0){
View message=alert.findViewById(messageID);
if(message!=null){
message.setPadding(message.getPaddingLeft(), message.getPaddingTop(), message.getPaddingRight(), V.dp(24));
}
}
return alert;
}
public M3AlertDialogBuilder setSupportingText(CharSequence text){
supportingText=text;
return this;
}
public M3AlertDialogBuilder setSupportingText(@StringRes int text){
supportingText=getContext().getString(text);
return this;
}
@Override
public M3AlertDialogBuilder setTitle(CharSequence title){
super.setTitle(title);
this.title=title;
return this;
}
@Override
public M3AlertDialogBuilder setTitle(@StringRes int title){
super.setTitle(title);
this.title=getContext().getString(title);
return this;
}
public M3AlertDialogBuilder setHelpText(CharSequence text){
helpText=text;
return this;
}
public M3AlertDialogBuilder setHelpText(@StringRes int text){
helpText=getContext().getString(text);
return this;
}
}

View File

@@ -10,6 +10,7 @@ import me.grishka.appkit.utils.V;
public class OutlineProviders{
private static final SparseArray<ViewOutlineProvider> roundedRects=new SparseArray<>();
private static final SparseArray<ViewOutlineProvider> topRoundedRects=new SparseArray<>();
private static final SparseArray<ViewOutlineProvider> bottomRoundedRects=new SparseArray<>();
private static final SparseArray<ViewOutlineProvider> endRoundedRects=new SparseArray<>();
public static final int RADIUS_XSMALL=4;
@@ -54,6 +55,15 @@ public class OutlineProviders{
return provider;
}
public static ViewOutlineProvider bottomRoundedRect(int dp){
ViewOutlineProvider provider=bottomRoundedRects.get(dp);
if(provider!=null)
return provider;
provider=new BottomRoundRectOutlineProvider(V.dp(dp));
bottomRoundedRects.put(dp, provider);
return provider;
}
public static ViewOutlineProvider endRoundedRect(int dp){
ViewOutlineProvider provider=endRoundedRects.get(dp);
if(provider!=null)
@@ -89,6 +99,19 @@ public class OutlineProviders{
}
}
private static class BottomRoundRectOutlineProvider extends ViewOutlineProvider{
private final int radius;
private BottomRoundRectOutlineProvider(int radius){
this.radius=radius;
}
@Override
public void getOutline(View view, Outline outline){
outline.setRoundRect(0, -radius, view.getWidth(), view.getHeight(), radius);
}
}
private static class EndRoundRectOutlineProvider extends ViewOutlineProvider{
private final int radius;

View File

@@ -0,0 +1,134 @@
package org.joinmastodon.android.ui;
import android.content.Context;
import android.content.res.ColorStateList;
import android.text.InputType;
import android.text.TextUtils;
import android.view.View;
import android.view.ViewGroup;
import android.view.inputmethod.EditorInfo;
import android.widget.EditText;
import android.widget.ImageButton;
import android.widget.LinearLayout;
import android.widget.Toolbar;
import org.joinmastodon.android.R;
import org.joinmastodon.android.ui.utils.SimpleTextWatcher;
import org.joinmastodon.android.ui.utils.UiUtils;
import java.util.function.Consumer;
import me.grishka.appkit.utils.V;
public class SearchViewHelper{
private LinearLayout searchLayout;
private EditText searchEdit;
private ImageButton clearSearchButton;
private View divider;
private String currentQuery;
private Consumer<String> listener;
private Runnable debouncer=()->{
currentQuery=searchEdit.getText().toString();
if(listener!=null){
listener.accept(currentQuery);
}
};
private boolean isEmpty=true;
private Runnable enterCallback;
private Consumer<String> listenerWithoutDebounce;
public SearchViewHelper(Context context, Context toolbarContext, String hint){
searchLayout=new LinearLayout(context);
searchLayout.setOrientation(LinearLayout.HORIZONTAL);
searchEdit=new EditText(context);
searchEdit.setHint(hint);
searchEdit.setInputType(InputType.TYPE_TEXT_VARIATION_FILTER);
searchEdit.setBackground(null);
searchEdit.setPadding(0, 0, 0, 0);
searchEdit.addTextChangedListener(new SimpleTextWatcher(e->{
searchEdit.removeCallbacks(debouncer);
searchEdit.postDelayed(debouncer, 300);
boolean newIsEmpty=e.length()==0;
if(isEmpty!=newIsEmpty){
isEmpty=newIsEmpty;
V.setVisibilityAnimated(clearSearchButton, isEmpty ? View.GONE : View.VISIBLE);
}
if(listenerWithoutDebounce!=null)
listenerWithoutDebounce.accept(e.toString());
}));
searchEdit.setImeOptions(EditorInfo.IME_ACTION_SEARCH);
searchEdit.setOnEditorActionListener((v, actionId, event)->{
searchEdit.removeCallbacks(debouncer);
debouncer.run();
if(enterCallback!=null)
enterCallback.run();
return true;
});
searchEdit.setTextAppearance(R.style.m3_body_large);
searchEdit.setHintTextColor(UiUtils.getThemeColor(toolbarContext, R.attr.colorM3OnSurfaceVariant));
searchEdit.setTextColor(UiUtils.getThemeColor(toolbarContext, R.attr.colorM3OnSurface));
searchLayout.addView(searchEdit, new LinearLayout.LayoutParams(0, ViewGroup.LayoutParams.MATCH_PARENT, 1f));
clearSearchButton=new ImageButton(context);
clearSearchButton.setImageResource(R.drawable.ic_baseline_close_24);
clearSearchButton.setContentDescription(context.getString(R.string.clear));
clearSearchButton.setImageTintList(ColorStateList.valueOf(UiUtils.getThemeColor(context, R.attr.colorM3OnSurfaceVariant)));
clearSearchButton.setBackground(UiUtils.getThemeDrawable(toolbarContext, android.R.attr.actionBarItemBackground));
clearSearchButton.setOnClickListener(v->{
searchEdit.setText("");
searchEdit.removeCallbacks(debouncer);
debouncer.run();
});
clearSearchButton.setVisibility(View.GONE);
searchLayout.addView(clearSearchButton, new LinearLayout.LayoutParams(V.dp(56), ViewGroup.LayoutParams.MATCH_PARENT));
}
public void setListeners(Consumer<String> listener, Consumer<String> listenerWithoutDebounce){
this.listener=listener;
this.listenerWithoutDebounce=listenerWithoutDebounce;
}
public void install(Toolbar toolbar){
toolbar.getLayoutParams().height=V.dp(72);
toolbar.setMinimumHeight(V.dp(72));
if(searchLayout.getParent()!=null)
((ViewGroup) searchLayout.getParent()).removeView(searchLayout);
toolbar.addView(searchLayout, new Toolbar.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.MATCH_PARENT));
toolbar.setBackgroundResource(R.drawable.bg_m3_surface3);
searchEdit.requestFocus();
}
public void addDivider(ViewGroup contentView){
divider=new View(contentView.getContext());
divider.setBackgroundColor(UiUtils.getThemeColor(contentView.getContext(), R.attr.colorM3Outline));
contentView.addView(divider, 1, new LinearLayout.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, V.dp(1)));
}
public LinearLayout getSearchLayout(){
return searchLayout;
}
public void setEnterCallback(Runnable enterCallback){
this.enterCallback=enterCallback;
}
public void setQuery(String q){
currentQuery=q;
searchEdit.setText(currentQuery);
searchEdit.setSelection(searchEdit.length());
searchEdit.removeCallbacks(debouncer);
}
public String getQuery(){
return currentQuery;
}
public View getDivider(){
return divider;
}
public EditText getSearchEdit(){
return searchEdit;
}
}

View File

@@ -0,0 +1,87 @@
package org.joinmastodon.android.ui;
import android.content.Context;
import android.view.View;
import android.widget.FrameLayout;
import java.util.Objects;
import androidx.annotation.NonNull;
import androidx.recyclerview.widget.RecyclerView;
public class StickyHeadersOverlay{
private static final String TAG="StickyHeadersOverlay";
private FrameLayout headerWrapper;
private Context context;
private RecyclerView parent;
private RecyclerView.ViewHolder currentHeaderHolder;
private int headerViewType;
public StickyHeadersOverlay(Context context, int headerViewType){
this.context=context;
this.headerViewType=headerViewType;
headerWrapper=new FrameLayout(context);
}
public void install(RecyclerView parent){
if(this.parent!=null)
throw new IllegalStateException();
this.parent=parent;
parent.getViewTreeObserver().addOnPreDrawListener(()->{
if(parent.getWidth()!=headerWrapper.getWidth() || parent.getHeight()!=headerWrapper.getHeight()){
headerWrapper.measure(parent.getWidth() | View.MeasureSpec.EXACTLY, parent.getHeight() | View.MeasureSpec.EXACTLY);
headerWrapper.layout(0, 0, parent.getWidth(), parent.getHeight());
}
return true;
});
parent.getOverlay().add(headerWrapper);
parent.addOnScrollListener(new RecyclerView.OnScrollListener(){
@Override
public void onScrolled(@NonNull RecyclerView recyclerView, int dx, int dy){
if(currentHeaderHolder==null){
currentHeaderHolder=parent.getAdapter().createViewHolder(parent, headerViewType);
headerWrapper.addView(currentHeaderHolder.itemView);
}
int firstVisiblePos=parent.getChildAdapterPosition(parent.getChildAt(0));
RecyclerView.Adapter<RecyclerView.ViewHolder> adapter=Objects.requireNonNull(parent.getAdapter());
// Go backwards from the first visible position to find the previous header
for(int i=firstVisiblePos;i>=0;i--){
if(adapter.getItemViewType(i)==headerViewType){
if(currentHeaderHolder.getAbsoluteAdapterPosition()!=i){
adapter.bindViewHolder(currentHeaderHolder, i);
}
break;
}
}
if(currentHeaderHolder instanceof HeaderViewHolder hvh){
hvh.setStickyFactor(firstVisiblePos==0 && parent.getChildAt(0).getTop()==0 ? 0 : 1);
}
// Now go forward and find the next header view to possibly offset the current one
for(int i=firstVisiblePos+1;i<adapter.getItemCount();i++){
if(adapter.getItemViewType(i)==headerViewType){
RecyclerView.ViewHolder holder=parent.findViewHolderForAdapterPosition(i);
if(holder!=null){
float factor;
if(holder.itemView.getTop()<currentHeaderHolder.itemView.getBottom()){
currentHeaderHolder.itemView.setTranslationY(holder.itemView.getTop()-currentHeaderHolder.itemView.getBottom());
factor=1f-holder.itemView.getTop()/(float)currentHeaderHolder.itemView.getBottom();
}else{
currentHeaderHolder.itemView.setTranslationY(0);
factor=0;
}
if(holder instanceof HeaderViewHolder hvh)
hvh.setStickyFactor(factor);
}
break;
}
}
}
});
}
public interface HeaderViewHolder{
void setStickyFactor(float factor);
}
}

View File

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

View File

@@ -0,0 +1,54 @@
package org.joinmastodon.android.ui.adapters;
import android.view.ViewGroup;
import org.joinmastodon.android.R;
import org.joinmastodon.android.model.viewmodel.ListItem;
import org.joinmastodon.android.ui.viewholders.CheckboxOrRadioListItemViewHolder;
import org.joinmastodon.android.ui.viewholders.ListItemViewHolder;
import org.joinmastodon.android.ui.viewholders.SimpleListItemViewHolder;
import org.joinmastodon.android.ui.viewholders.SwitchListItemViewHolder;
import java.util.List;
import androidx.annotation.NonNull;
import androidx.recyclerview.widget.RecyclerView;
public class GenericListItemsAdapter<T> extends RecyclerView.Adapter<ListItemViewHolder<?>>{
private List<ListItem<T>> items;
public GenericListItemsAdapter(List<ListItem<T>> items){
this.items=items;
}
@NonNull
@Override
public ListItemViewHolder<?> onCreateViewHolder(@NonNull ViewGroup parent, int viewType){
if(viewType==R.id.list_item_simple || viewType==R.id.list_item_simple_tinted)
return new SimpleListItemViewHolder(parent.getContext(), parent);
if(viewType==R.id.list_item_switch)
return new SwitchListItemViewHolder(parent.getContext(), parent);
if(viewType==R.id.list_item_checkbox)
return new CheckboxOrRadioListItemViewHolder(parent.getContext(), parent, false);
if(viewType==R.id.list_item_radio)
return new CheckboxOrRadioListItemViewHolder(parent.getContext(), parent, true);
throw new IllegalArgumentException("Unexpected view type "+viewType);
}
@SuppressWarnings("unchecked")
@Override
public void onBindViewHolder(@NonNull ListItemViewHolder<?> holder, int position){
((ListItemViewHolder<ListItem<T>>)holder).bind(items.get(position));
}
@Override
public int getItemCount(){
return items.size();
}
@Override
public int getItemViewType(int position){
return items.get(position).getItemViewType();
}
}

View File

@@ -0,0 +1,36 @@
package org.joinmastodon.android.ui.adapters;
import android.view.ViewGroup;
import org.joinmastodon.android.model.Instance;
import org.joinmastodon.android.ui.viewholders.InstanceRuleViewHolder;
import java.util.List;
import androidx.annotation.NonNull;
import androidx.recyclerview.widget.RecyclerView;
public class InstanceRulesAdapter extends RecyclerView.Adapter<InstanceRuleViewHolder>{
private final List<Instance.Rule> rules;
public InstanceRulesAdapter(List<Instance.Rule> rules){
this.rules=rules;
}
@NonNull
@Override
public InstanceRuleViewHolder onCreateViewHolder(@NonNull ViewGroup parent, int viewType){
return new InstanceRuleViewHolder(parent);
}
@Override
public void onBindViewHolder(@NonNull InstanceRuleViewHolder holder, int position){
holder.setPosition(position);
holder.bind(rules.get(position));
}
@Override
public int getItemCount(){
return rules.size();
}
}

View File

@@ -132,7 +132,7 @@ public class AccountCardStatusDisplayItem extends StatusDisplayItem{
postsCount.setText(UiUtils.abbreviateNumber(item.account.statusesCount));
followersLabel.setText(item.parentFragment.getResources().getQuantityString(R.plurals.followers, (int)Math.min(999, item.account.followersCount)));
followingLabel.setText(item.parentFragment.getResources().getQuantityString(R.plurals.following, (int)Math.min(999, item.account.followingCount)));
postsLabel.setText(item.parentFragment.getResources().getQuantityString(R.plurals.posts, (int)Math.min(999, item.account.statusesCount)));
postsLabel.setText(item.parentFragment.getResources().getQuantityString(R.plurals.x_posts, (int)(item.account.statusesCount%1000), item.account.statusesCount));
followersCount.setVisibility(item.account.followersCount < 0 ? View.GONE : View.VISIBLE);
followersLabel.setVisibility(item.account.followersCount < 0 ? View.GONE : View.VISIBLE);
followingCount.setVisibility(item.account.followingCount < 0 ? View.GONE : View.VISIBLE);
@@ -156,13 +156,14 @@ public class AccountCardStatusDisplayItem extends StatusDisplayItem{
actionWrap.setVisibility(View.VISIBLE);
acceptWrap.setVisibility(View.GONE);
rejectWrap.setVisibility(View.GONE);
UiUtils.setRelationshipToActionButton(relationship, actionButton);
UiUtils.setRelationshipToActionButtonM3(relationship, actionButton);
}
}
private void onFollowRequestButtonClick(View v) {
itemView.setHasTransientState(true);
UiUtils.handleFollowRequest((Activity) v.getContext(), item.account, item.parentFragment.getAccountID(), null, v == acceptButton, relationship, rel -> {
if(v.getContext()==null) return;
itemView.setHasTransientState(false);
item.parentFragment.putRelationship(item.account.id, rel);
RecyclerView.Adapter<? extends RecyclerView.ViewHolder> adapter = getBindingAdapter();
@@ -180,6 +181,7 @@ public class AccountCardStatusDisplayItem extends StatusDisplayItem{
private void onActionButtonClick(View v){
itemView.setHasTransientState(true);
UiUtils.performAccountAction((Activity) v.getContext(), item.account, item.parentFragment.getAccountID(), relationship, actionButton, this::setActionProgressVisible, rel->{
if(v.getContext()==null) return;
itemView.setHasTransientState(false);
item.parentFragment.putRelationship(item.account.id, rel);
rebind();

View File

@@ -1,38 +1,21 @@
package org.joinmastodon.android.ui.displayitems;
import android.content.Context;
import android.graphics.drawable.Animatable;
import android.graphics.drawable.Drawable;
import android.text.TextUtils;
import android.view.ViewGroup;
import android.widget.ImageView;
import android.widget.TextView;
import org.joinmastodon.android.R;
import org.joinmastodon.android.fragments.BaseStatusListFragment;
import org.joinmastodon.android.model.Account;
import org.joinmastodon.android.ui.OutlineProviders;
import org.joinmastodon.android.ui.text.HtmlParser;
import org.joinmastodon.android.ui.utils.CustomEmojiHelper;
import org.joinmastodon.android.model.viewmodel.AccountViewModel;
import org.joinmastodon.android.ui.viewholders.AccountViewHolder;
import me.grishka.appkit.imageloader.ImageLoaderViewHolder;
import me.grishka.appkit.imageloader.requests.ImageLoaderRequest;
import me.grishka.appkit.imageloader.requests.UrlImageLoaderRequest;
import me.grishka.appkit.utils.V;
public class AccountStatusDisplayItem extends StatusDisplayItem{
public final Account account;
private CustomEmojiHelper emojiHelper=new CustomEmojiHelper();
private CharSequence parsedName;
public ImageLoaderRequest avaRequest;
public final AccountViewModel account;
public AccountStatusDisplayItem(String parentID, BaseStatusListFragment parentFragment, Account account){
super(parentID, parentFragment);
this.account=account;
parsedName=HtmlParser.parseCustomEmoji(account.displayName, account.emojis);
emojiHelper.setText(parsedName);
if(!TextUtils.isEmpty(account.avatar))
avaRequest=new UrlImageLoaderRequest(account.avatar, V.dp(50), V.dp(50));
this.account=new AccountViewModel(account, parentFragment.getAccountID());
}
@Override
@@ -42,51 +25,43 @@ public class AccountStatusDisplayItem extends StatusDisplayItem{
@Override
public int getImageCount(){
return 1+emojiHelper.getImageCount();
return 1+account.emojiHelper.getImageCount();
}
@Override
public ImageLoaderRequest getImageRequest(int index){
if(index==0)
return avaRequest;
return emojiHelper.getImageRequest(index-1);
return account.avaRequest;
return account.emojiHelper.getImageRequest(index-1);
}
public static class Holder extends StatusDisplayItem.Holder<AccountStatusDisplayItem> implements ImageLoaderViewHolder{
private final TextView name, username;
private final ImageView photo;
private final AccountViewHolder realHolder;
public Holder(Context context, ViewGroup parent){
super(context, R.layout.display_item_account, parent);
name=findViewById(R.id.name);
username=findViewById(R.id.username);
photo=findViewById(R.id.photo);
photo.setOutlineProvider(OutlineProviders.roundedRect(12));
photo.setClipToOutline(true);
public Holder(AccountViewHolder realHolder){
super(realHolder.itemView);
this.realHolder=realHolder;
realHolder.setStyle(AccountViewHolder.AccessoryType.NONE, false);
}
@Override
public void onBind(AccountStatusDisplayItem item){
name.setText(item.parsedName);
username.setText("@"+item.account.acct);
realHolder.bind(item.account);
}
@Override
public void setImage(int index, Drawable image){
if(image instanceof Animatable && !((Animatable) image).isRunning())
((Animatable) image).start();
if(index==0){
photo.setImageDrawable(image);
}else{
item.emojiHelper.setImageDrawable(index-1, image);
name.invalidate();
}
realHolder.setImage(index, image);
}
@Override
public void clearImage(int index){
setImage(index, null);
realHolder.clearImage(index);
}
@Override
public void onClick(){
realHolder.onClick();
}
}
}

View File

@@ -1,11 +1,19 @@
package org.joinmastodon.android.ui.displayitems;
import android.annotation.SuppressLint;
import android.content.Context;
import android.graphics.Bitmap;
import android.graphics.Color;
import android.graphics.drawable.BitmapDrawable;
import android.graphics.drawable.Drawable;
import android.os.Build;
import android.os.SystemClock;
import android.text.TextUtils;
import android.view.View;
import android.view.ViewGroup;
import android.widget.FrameLayout;
import android.widget.ImageButton;
import android.widget.SeekBar;
import android.widget.ImageView;
import android.widget.TextView;
import org.joinmastodon.android.AudioPlayerService;
@@ -13,16 +21,26 @@ import org.joinmastodon.android.R;
import org.joinmastodon.android.fragments.BaseStatusListFragment;
import org.joinmastodon.android.model.Attachment;
import org.joinmastodon.android.model.Status;
import org.joinmastodon.android.ui.drawables.SeekBarThumbDrawable;
import org.joinmastodon.android.ui.OutlineProviders;
import org.joinmastodon.android.ui.drawables.AudioAttachmentBackgroundDrawable;
import org.joinmastodon.android.ui.utils.UiUtils;
import androidx.palette.graphics.Palette;
import me.grishka.appkit.imageloader.ImageLoaderViewHolder;
import me.grishka.appkit.imageloader.requests.ImageLoaderRequest;
import me.grishka.appkit.imageloader.requests.UrlImageLoaderRequest;
import me.grishka.appkit.utils.V;
public class AudioStatusDisplayItem extends StatusDisplayItem{
public final Status status;
public final Attachment attachment;
private final ImageLoaderRequest imageRequest;
public AudioStatusDisplayItem(String parentID, BaseStatusListFragment parentFragment, Status status, Attachment attachment){
super(parentID, parentFragment);
this.status=status;
this.attachment=attachment;
imageRequest=new UrlImageLoaderRequest(TextUtils.isEmpty(attachment.previewUrl) ? status.account.avatarStatic : attachment.previewUrl, V.dp(100), V.dp(100));
}
@Override
@@ -30,25 +48,38 @@ public class AudioStatusDisplayItem extends StatusDisplayItem{
return Type.AUDIO;
}
public static class Holder extends StatusDisplayItem.Holder<AudioStatusDisplayItem> implements AudioPlayerService.Callback{
private final ImageButton playPauseBtn;
@Override
public int getImageCount(){
return 1;
}
@Override
public ImageLoaderRequest getImageRequest(int index){
return imageRequest;
}
public static class Holder extends StatusDisplayItem.Holder<AudioStatusDisplayItem> implements AudioPlayerService.Callback, ImageLoaderViewHolder{
private final ImageButton playPauseBtn, forwardBtn, rewindBtn;
private final TextView time;
private final SeekBar seekBar;
private final ImageView image;
private final FrameLayout content;
private final AudioAttachmentBackgroundDrawable bgDrawable;
private int lastKnownPosition;
private long lastKnownPositionTime;
private boolean playing;
private int lastRemainingSeconds=-1;
private boolean seekbarBeingDragged;
private int lastPosSeconds=-1;
private AudioPlayerService.PlayState state;
private Runnable positionUpdater=this::updatePosition;
private final Runnable positionUpdater=this::updatePosition;
public Holder(Context context, ViewGroup parent){
super(context, R.layout.display_item_audio, parent);
playPauseBtn=findViewById(R.id.play_pause_btn);
time=findViewById(R.id.time);
seekBar=findViewById(R.id.seekbar);
seekBar.setThumb(new SeekBarThumbDrawable(context));
image=findViewById(R.id.image);
content=findViewById(R.id.content);
forwardBtn=findViewById(R.id.forward_btn);
rewindBtn=findViewById(R.id.rewind_btn);
playPauseBtn.setOnClickListener(this::onPlayPauseClick);
itemView.addOnAttachStateChangeListener(new View.OnAttachStateChangeListener(){
@Override
@@ -61,76 +92,71 @@ public class AudioStatusDisplayItem extends StatusDisplayItem{
AudioPlayerService.unregisterCallback(Holder.this);
}
});
seekBar.setOnSeekBarChangeListener(new SeekBar.OnSeekBarChangeListener(){
@Override
public void onProgressChanged(SeekBar seekBar, int progress, boolean fromUser){
if(fromUser){
int seconds=(int)(seekBar.getProgress()/10000.0*item.attachment.getDuration());
time.setText(formatDuration(seconds));
}
}
forwardBtn.setOnClickListener(this::onSeekButtonClick);
rewindBtn.setOnClickListener(this::onSeekButtonClick);
@Override
public void onStartTrackingTouch(SeekBar seekBar){
seekbarBeingDragged=true;
}
@Override
public void onStopTrackingTouch(SeekBar seekBar){
AudioPlayerService service=AudioPlayerService.getInstance();
if(service!=null && service.getAttachmentID().equals(item.attachment.id)){
service.seekTo((int)(seekBar.getProgress()/10000.0*item.attachment.getDuration()*1000.0));
}
seekbarBeingDragged=false;
if(playing)
itemView.postOnAnimation(positionUpdater);
}
});
image.setOutlineProvider(OutlineProviders.OVAL);
image.setClipToOutline(true);
content.setBackground(bgDrawable=new AudioAttachmentBackgroundDrawable());
}
@Override
public void onBind(AudioStatusDisplayItem item){
int seconds=(int)item.attachment.getDuration();
String duration=formatDuration(seconds);
// Some fonts (not Roboto) have different-width digits. 0 is supposedly the widest.
time.getLayoutParams().width=(int)Math.ceil(Math.max(time.getPaint().measureText("-"+duration),
time.getPaint().measureText("-"+duration.replaceAll("\\d", "0"))));
time.setText(duration);
String duration=UiUtils.formatMediaDuration(seconds);
AudioPlayerService service=AudioPlayerService.getInstance();
if(service!=null && service.getAttachmentID().equals(item.attachment.id)){
seekBar.setEnabled(true);
onPlayStateChanged(item.attachment.id, service.isPlaying(), service.getPosition());
forwardBtn.setVisibility(View.VISIBLE);
rewindBtn.setVisibility(View.VISIBLE);
onPlayStateChanged(item.attachment.id, service.getPlayState(), service.getPosition());
actuallyUpdatePosition();
}else{
seekBar.setEnabled(false);
state=null;
time.setText(duration);
forwardBtn.setVisibility(View.INVISIBLE);
rewindBtn.setVisibility(View.INVISIBLE);
setPlayButtonPlaying(false, false);
}
int mainColor;
if(item.attachment.meta!=null && item.attachment.meta.colors!=null){
try{
mainColor=Color.parseColor(item.attachment.meta.colors.background);
}catch(IllegalArgumentException x){
mainColor=0xff808080;
}
}else{
mainColor=0xff808080;
}
updateColors(mainColor);
}
private void onPlayPauseClick(View v){
AudioPlayerService service=AudioPlayerService.getInstance();
if(service!=null && service.getAttachmentID().equals(item.attachment.id)){
if(playing)
if(state!=AudioPlayerService.PlayState.PAUSED)
service.pause(true);
else
service.play();
}else{
AudioPlayerService.start(v.getContext(), item.status, item.attachment);
onPlayStateChanged(item.attachment.id, true, 0);
seekBar.setEnabled(true);
onPlayStateChanged(item.attachment.id, AudioPlayerService.PlayState.BUFFERING, 0);
forwardBtn.setVisibility(View.VISIBLE);
rewindBtn.setVisibility(View.VISIBLE);
}
}
@Override
public void onPlayStateChanged(String attachmentID, boolean playing, int position){
public void onPlayStateChanged(String attachmentID, AudioPlayerService.PlayState state, int position){
if(attachmentID.equals(item.attachment.id)){
this.lastKnownPosition=position;
lastKnownPositionTime=SystemClock.uptimeMillis();
this.playing=playing;
playPauseBtn.setImageResource(playing ? R.drawable.ic_fluent_pause_circle_24_filled : R.drawable.ic_fluent_play_circle_24_filled);
if(!playing){
lastRemainingSeconds=-1;
time.setText(formatDuration((int) item.attachment.getDuration()));
}else{
this.state=state;
setPlayButtonPlaying(state!=AudioPlayerService.PlayState.PAUSED, true);
if(state==AudioPlayerService.PlayState.PLAYING){
itemView.postOnAnimation(positionUpdater);
}else if(state==AudioPlayerService.PlayState.BUFFERING){
actuallyUpdatePosition();
}
}
}
@@ -138,32 +164,88 @@ public class AudioStatusDisplayItem extends StatusDisplayItem{
@Override
public void onPlaybackStopped(String attachmentID){
if(attachmentID.equals(item.attachment.id)){
playing=false;
playPauseBtn.setImageResource(R.drawable.ic_fluent_play_circle_24_filled);
seekBar.setProgress(0);
seekBar.setEnabled(false);
time.setText(formatDuration((int)item.attachment.getDuration()));
state=null;
setPlayButtonPlaying(false, true);
forwardBtn.setVisibility(View.INVISIBLE);
rewindBtn.setVisibility(View.INVISIBLE);
time.setText(UiUtils.formatMediaDuration((int)item.attachment.getDuration()));
}
}
private String formatDuration(int seconds){
if(seconds>=3600)
return String.format("%d:%02d:%02d", seconds/3600, seconds%3600/60, seconds%60);
else
return String.format("%d:%02d", seconds/60, seconds%60);
}
private void updatePosition(){
if(!playing || seekbarBeingDragged)
if(state!=AudioPlayerService.PlayState.PLAYING)
return;
double pos=lastKnownPosition/1000.0+(SystemClock.uptimeMillis()-lastKnownPositionTime)/1000.0;
seekBar.setProgress((int)Math.round(pos/item.attachment.getDuration()*10000.0));
actuallyUpdatePosition();
itemView.postOnAnimation(positionUpdater);
int remainingSeconds=(int)(item.attachment.getDuration()-pos);
if(remainingSeconds!=lastRemainingSeconds){
lastRemainingSeconds=remainingSeconds;
time.setText("-"+formatDuration(remainingSeconds));
}
@SuppressLint("SetTextI18n")
private void actuallyUpdatePosition(){
double pos=lastKnownPosition/1000.0;
if(state==AudioPlayerService.PlayState.PLAYING)
pos+=(SystemClock.uptimeMillis()-lastKnownPositionTime)/1000.0;
int posSeconds=(int)pos;
if(posSeconds!=lastPosSeconds){
lastPosSeconds=posSeconds;
time.setText(UiUtils.formatMediaDuration(posSeconds)+"/"+UiUtils.formatMediaDuration((int)item.attachment.getDuration()));
}
}
private void updateColors(int mainColor){
float[] hsv={0, 0, 0};
float[] hsv2={0, 0, 0};
Color.colorToHSV(mainColor, hsv);
boolean isGray=hsv[1]<0.2f;
boolean isDarkTheme=UiUtils.isDarkTheme();
hsv2[0]=hsv[0];
hsv2[1]=isGray ? hsv[1] : (isDarkTheme ? 0.6f : 0.4f);
hsv2[2]=isDarkTheme ? 0.3f : 0.75f;
int bgColor=Color.HSVToColor(hsv2);
hsv2[1]=isGray ? hsv[1] : (isDarkTheme ? 0.3f : 0.6f);
hsv2[2]=isDarkTheme ? 0.6f : 0.4f;
bgDrawable.setColors(bgColor, Color.HSVToColor(128, hsv2));
hsv2[1]=isGray ? hsv[1] : 0.1f;
hsv2[2]=1;
int controlsColor=Color.HSVToColor(hsv2);
time.setTextColor(controlsColor);
forwardBtn.setColorFilter(controlsColor);
rewindBtn.setColorFilter(controlsColor);
}
private void setPlayButtonPlaying(boolean playing, boolean animated){
playPauseBtn.setImageResource(playing ? R.drawable.ic_fluent_pause_48_regular : R.drawable.ic_fluent_play_48_regular);
playPauseBtn.setContentDescription(item.parentFragment.getString(playing ? R.string.pause : R.string.play));
if(playing)
bgDrawable.startAnimation();
else
bgDrawable.stopAnimation(animated);
}
private void onSeekButtonClick(View v){
int seekAmount=v.getId()==R.id.forward_btn ? 10_000 : -5_000;
AudioPlayerService service=AudioPlayerService.getInstance();
if(service!=null && service.getAttachmentID().equals(item.attachment.id)){
int newPos=Math.min(Math.max(0, service.getPosition()+seekAmount), (int)(item.attachment.getDuration()*1000));
service.seekTo(newPos);
}
}
@Override
public void setImage(int index, Drawable image){
this.image.setImageDrawable(image);
if((item.attachment.meta==null || item.attachment.meta.colors==null) && image instanceof BitmapDrawable bd){
Bitmap bitmap=bd.getBitmap();
if(Build.VERSION.SDK_INT>=26 && bitmap.getConfig()==Bitmap.Config.HARDWARE)
bitmap=bitmap.copy(Bitmap.Config.ARGB_8888, false);
int color=Palette.from(bitmap).maximumColorCount(1).generate().getDominantColor(0xff808080);
updateColors(color);
}
}
@Override
public void clearImage(int index){
setImage(index, null);
}
}
}

View File

@@ -0,0 +1,53 @@
package org.joinmastodon.android.ui.displayitems;
import android.app.Activity;
import android.view.View;
import android.view.ViewGroup;
import android.widget.CheckBox;
import org.joinmastodon.android.R;
import org.joinmastodon.android.fragments.BaseStatusListFragment;
import org.joinmastodon.android.model.Account;
import org.joinmastodon.android.model.Notification;
import org.joinmastodon.android.model.ScheduledStatus;
import org.joinmastodon.android.model.Status;
import org.joinmastodon.android.ui.views.CheckableRelativeLayout;
import java.time.Instant;
import java.util.function.Predicate;
public class CheckableHeaderStatusDisplayItem extends HeaderStatusDisplayItem{
public CheckableHeaderStatusDisplayItem(String parentID, Account user, Instant createdAt, BaseStatusListFragment<?> parentFragment, String accountID, Status status, CharSequence extraText){
super(parentID, user, createdAt, parentFragment, accountID, status, extraText, null, null);
}
@Override
public Type getType(){
return Type.HEADER_CHECKABLE;
}
public static class Holder extends HeaderStatusDisplayItem.Holder{
private final View checkbox;
private final CheckableRelativeLayout view;
private Predicate<Holder> isChecked;
public Holder(Activity activity, ViewGroup parent){
super(activity, R.layout.display_item_header_checkable, parent);
checkbox=findViewById(R.id.checkbox);
view=(CheckableRelativeLayout) itemView;
checkbox.setBackground(new CheckBox(activity).getButtonDrawable());
}
@Override
public void onBind(HeaderStatusDisplayItem item){
super.onBind(item);
if(isChecked!=null){
view.setChecked(isChecked.test(this));
}
}
public void setIsChecked(Predicate<Holder> isChecked){
this.isChecked=isChecked;
}
}
}

View File

@@ -20,6 +20,7 @@ import org.joinmastodon.android.fragments.account_list.StatusFavoritesListFragme
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.model.StatusPrivacy;
import org.joinmastodon.android.ui.utils.UiUtils;
import org.parceler.Parcels;
@@ -75,11 +76,11 @@ public class ExtendedFooterStatusDisplayItem extends StatusDisplayItem{
Status s=item.status;
favorites.setText(context.getResources().getQuantityString(R.plurals.x_favorites, (int)(s.favouritesCount%1000), s.favouritesCount));
reblogs.setText(context.getResources().getQuantityString(R.plurals.x_reblogs, (int) (s.reblogsCount % 1000), s.reblogsCount));
reblogs.setVisibility(s.isReblogPermitted(item.accountID) ? View.VISIBLE : View.GONE);
reblogs.setVisibility(s.visibility != StatusPrivacy.DIRECT ? View.VISIBLE : View.GONE);
if(s.editedAt!=null){
editHistory.setVisibility(View.VISIBLE);
editHistory.setText(UiUtils.formatRelativeTimestampAsMinutesAgo(itemView.getContext(), s.editedAt));
editHistory.setText(UiUtils.formatRelativeTimestampAsMinutesAgo(itemView.getContext(), s.editedAt, false));
}else{
editHistory.setVisibility(View.GONE);
}

View File

@@ -149,7 +149,8 @@ public class FooterStatusDisplayItem extends StatusDisplayItem{
}
private void bindText(TextView btn, long count){
if(GlobalUserPreferences.showInteractionCounts && count>0 && !item.hideCounts){
if(AccountSessionManager.get(item.accountID).getLocalPreferences().showInteractionCounts
&& count>0 && !item.hideCounts){
btn.setText(UiUtils.abbreviateNumber(count));
btn.setCompoundDrawablePadding(V.dp(8));
}else{
@@ -205,7 +206,7 @@ public class FooterStatusDisplayItem extends StatusDisplayItem{
}
private void onBoostClick(View v){
if (GlobalUserPreferences.confirmBeforeReblog) {
if (GlobalUserPreferences.confirmBoost) {
v.startAnimation(opacityIn);
onBoostLongClick(v);
return;

View File

@@ -3,7 +3,6 @@ package org.joinmastodon.android.ui.displayitems;
import android.content.Context;
import android.view.View;
import android.view.ViewGroup;
import android.widget.RelativeLayout;
import android.widget.TextView;
import org.joinmastodon.android.R;
@@ -27,13 +26,6 @@ public class HashtagStatusDisplayItem extends StatusDisplayItem{
public static class Holder extends StatusDisplayItem.Holder<HashtagStatusDisplayItem>{
private final TextView title, subtitle;
private final HashtagChartView chart;
public static final RelativeLayout.LayoutParams
withHistoryParams = new RelativeLayout.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.WRAP_CONTENT),
withoutHistoryParams = new RelativeLayout.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.WRAP_CONTENT);
static {
withoutHistoryParams.addRule(RelativeLayout.CENTER_VERTICAL, RelativeLayout.TRUE);
}
public Holder(Context context, ViewGroup parent){
super(context, R.layout.item_trending_hashtag, parent);
@@ -46,20 +38,17 @@ public class HashtagStatusDisplayItem extends StatusDisplayItem{
public void onBind(HashtagStatusDisplayItem _item){
Hashtag item=_item.tag;
title.setText('#'+item.name);
if (item.history == null || item.history.isEmpty()) {
subtitle.setText(null);
if(item.history!=null && !item.history.isEmpty()){
int numPeople=item.history.get(0).accounts;
if(item.history.size()>1)
numPeople+=item.history.get(1).accounts;
subtitle.setText(itemView.getResources().getQuantityString(R.plurals.x_people_talking, numPeople, numPeople));
chart.setData(item.history);
chart.setVisibility(View.VISIBLE);
}else{
subtitle.setText(itemView.getResources().getQuantityString(R.plurals.x_posts, item.statusesCount, item.statusesCount));
chart.setVisibility(View.GONE);
title.setLayoutParams(withoutHistoryParams);
return;
}
chart.setVisibility(View.VISIBLE);
title.setLayoutParams(withHistoryParams);
int numPeople=item.history.get(0).accounts;
if(item.history.size()>1)
numPeople+=item.history.get(1).accounts;
subtitle.setText(_item.parentFragment.getResources().getQuantityString(R.plurals.x_people_talking, numPeople, numPeople));
chart.setData(item.history);
}
}
}

View File

@@ -1,8 +1,8 @@
package org.joinmastodon.android.ui.displayitems;
import android.annotation.SuppressLint;
import android.app.Activity;
import android.app.ProgressDialog;
import android.graphics.Outline;
import android.graphics.Paint;
import android.graphics.Typeface;
import android.graphics.drawable.Animatable;
@@ -16,7 +16,6 @@ import android.view.MenuItem;
import android.view.SubMenu;
import android.view.View;
import android.view.ViewGroup;
import android.view.ViewOutlineProvider;
import android.widget.ImageView;
import android.widget.PopupMenu;
import android.widget.TextView;
@@ -39,11 +38,12 @@ import org.joinmastodon.android.fragments.ThreadFragment;
import org.joinmastodon.android.fragments.report.ReportReasonChoiceFragment;
import org.joinmastodon.android.model.Account;
import org.joinmastodon.android.model.Announcement;
import org.joinmastodon.android.model.Attachment;
import org.joinmastodon.android.model.Notification;
import org.joinmastodon.android.model.Relationship;
import org.joinmastodon.android.model.ScheduledStatus;
import org.joinmastodon.android.model.Status;
import org.joinmastodon.android.model.StatusPrivacy;
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;
@@ -58,6 +58,7 @@ import java.util.List;
import java.util.Locale;
import java.util.function.Consumer;
import androidx.annotation.LayoutRes;
import me.grishka.appkit.Nav;
import me.grishka.appkit.api.APIRequest;
import me.grishka.appkit.api.Callback;
@@ -94,18 +95,12 @@ public class HeaderStatusDisplayItem extends StatusDisplayItem{
this.status=status;
this.notification=notification;
this.scheduledStatus=scheduledStatus;
HtmlParser.parseCustomEmoji(parsedName, user.emojis);
if(AccountSessionManager.get(accountID).getLocalPreferences().customEmojiInNames)
HtmlParser.parseCustomEmoji(parsedName, user.emojis);
emojiHelper.setText(parsedName);
if(status!=null){
hasVisibilityToggle=status.sensitive || !TextUtils.isEmpty(status.spoilerText);
if(!hasVisibilityToggle && !status.mediaAttachments.isEmpty()){
for(Attachment att:status.mediaAttachments){
if(att.type!=Attachment.Type.AUDIO){
hasVisibilityToggle=true;
break;
}
}
}
// visibility toggle can't do much for non-"image" attachments
hasVisibilityToggle=status.mediaAttachments.stream().anyMatch(m -> m.type.isImage());
}
this.extraText=extraText;
emojiHelper.addText(extraText);
@@ -137,36 +132,33 @@ public class HeaderStatusDisplayItem extends StatusDisplayItem{
}
public static class Holder extends StatusDisplayItem.Holder<HeaderStatusDisplayItem> implements ImageLoaderViewHolder{
private final TextView name, username, timestamp, extraText, separator;
private final TextView name, timeAndUsername, extraText, pronouns;
private final View collapseBtn;
private final ImageView avatar, more, visibility, deleteNotification, unreadIndicator, collapseBtnIcon;
private final ImageView avatar, more, visibility, deleteNotification, unreadIndicator, markAsRead, collapseBtnIcon;
private final PopupMenu optionsMenu;
private Relationship relationship;
private APIRequest<?> currentRelationshipRequest;
private static final ViewOutlineProvider roundCornersOutline=new ViewOutlineProvider(){
@Override
public void getOutline(View view, Outline outline){
outline.setRoundRect(0, 0, view.getWidth(), view.getHeight(), V.dp(12));
}
};
public Holder(Activity activity, ViewGroup parent){
super(activity, R.layout.display_item_header, parent);
this(activity, R.layout.display_item_header, parent);
}
protected Holder(Activity activity, @LayoutRes int layout, ViewGroup parent){
super(activity, layout, parent);
name=findViewById(R.id.name);
username=findViewById(R.id.username);
separator=findViewById(R.id.separator);
timestamp=findViewById(R.id.timestamp);
timeAndUsername=findViewById(R.id.time_and_username);
avatar=findViewById(R.id.avatar);
more=findViewById(R.id.more);
visibility=findViewById(R.id.visibility);
deleteNotification=findViewById(R.id.delete_notification);
unreadIndicator=findViewById(R.id.unread_indicator);
markAsRead=findViewById(R.id.mark_as_read);
collapseBtn=findViewById(R.id.collapse_btn);
collapseBtnIcon=findViewById(R.id.collapse_btn_icon);
extraText=findViewById(R.id.extra_text);
pronouns=findViewById(R.id.pronouns);
avatar.setOnClickListener(this::onAvaClick);
avatar.setOutlineProvider(roundCornersOutline);
avatar.setOutlineProvider(OutlineProviders.roundedRect(12));
avatar.setClipToOutline(true);
more.setOnClickListener(this::onMoreClick);
visibility.setOnClickListener(v->item.parentFragment.onVisibilityIconClick(this));
@@ -179,15 +171,16 @@ public class HeaderStatusDisplayItem extends StatusDisplayItem{
optionsMenu=new PopupMenu(activity, more);
optionsMenu.inflate(R.menu.post);
if(Build.VERSION.SDK_INT>=Build.VERSION_CODES.P)
optionsMenu.getMenu().setGroupDividerEnabled(true);
optionsMenu.setOnMenuItemClickListener(menuItem->{
Account account=item.user;
int id=menuItem.getItemId();
if(id==R.id.edit || id==R.id.delete_and_redraft) {
if(id==R.id.edit || id==R.id.delete_and_redraft){
final Bundle args=new Bundle();
args.putString("account", item.parentFragment.getAccountID());
args.putParcelable("editStatus", Parcels.wrap(item.status));
boolean redraft = id==R.id.delete_and_redraft;
boolean redraft = id == R.id.delete_and_redraft;
if (redraft) {
args.putBoolean("redraftStatus", true);
if (item.parentFragment instanceof ThreadFragment thread && !thread.isItemEnabled(item.status.id)) {
@@ -255,8 +248,9 @@ public class HeaderStatusDisplayItem extends StatusDisplayItem{
args.putString("account", item.parentFragment.getAccountID());
args.putParcelable("status", Parcels.wrap(item.status));
args.putParcelable("reportAccount", Parcels.wrap(item.status.account));
args.putParcelable("relationship", Parcels.wrap(relationship));
Nav.go(item.parentFragment.getActivity(), ReportReasonChoiceFragment.class, args);
}else if(id==R.id.open_in_browser) {
}else if(id==R.id.open_in_browser){
UiUtils.launchWebBrowser(activity, item.status.url);
}else if(id==R.id.copy_link){
UiUtils.copyText(parent, item.status.url);
@@ -285,6 +279,8 @@ public class HeaderStatusDisplayItem extends StatusDisplayItem{
args.putString("profileAccount", account.id);
args.putString("profileDisplayUsername", account.getDisplayUsername());
Nav.go(item.parentFragment.getActivity(), ListsFragment.class, args);
}else if(id==R.id.share){
UiUtils.openSystemShareSheet(activity, item.status.url);
}
return true;
});
@@ -302,47 +298,55 @@ public class HeaderStatusDisplayItem extends StatusDisplayItem{
});
}
@SuppressLint("SetTextI18n")
@Override
public void onBind(HeaderStatusDisplayItem item){
name.setText(item.parsedName);
username.setText('@'+item.user.acct);
separator.setVisibility(View.VISIBLE);
if (item.scheduledStatus!=null)
String time = null;
if (item.scheduledStatus!=null) {
if (item.scheduledStatus.scheduledAt.isAfter(CreateStatus.DRAFTS_AFTER_INSTANT)) {
timestamp.setText(R.string.sk_draft);
time = item.parentFragment.getString(R.string.sk_draft);
} else {
DateTimeFormatter formatter = DateTimeFormatter.ofLocalizedDateTime(FormatStyle.MEDIUM).withLocale(Locale.getDefault());
timestamp.setText(item.scheduledStatus.scheduledAt.atZone(ZoneId.systemDefault()).format(formatter));
time = item.scheduledStatus.scheduledAt.atZone(ZoneId.systemDefault()).format(formatter);
}
else if ((!item.inset || item.status==null || item.status.editedAt==null) && item.createdAt != null)
timestamp.setText(UiUtils.formatRelativeTimestamp(itemView.getContext(), item.createdAt));
} else if(item.status==null || item.status.editedAt==null)
time=UiUtils.formatRelativeTimestamp(itemView.getContext(), item.createdAt);
else if (item.status != null && item.status.editedAt != null)
timestamp.setText(item.parentFragment.getString(R.string.edited_timestamp, UiUtils.formatRelativeTimestamp(itemView.getContext(), item.status.editedAt)));
else {
separator.setVisibility(View.GONE);
timestamp.setText("");
}
visibility.setVisibility(item.hasVisibilityToggle && !item.inset ? View.VISIBLE : View.GONE);
time=item.parentFragment.getString(R.string.edited_timestamp, UiUtils.formatRelativeTimestamp(itemView.getContext(), item.status.editedAt));
String sepp = item.parentFragment.getString(R.string.sk_separator);
String username = "@" + item.user.acct;
timeAndUsername.setText(time == null ? username :
username + " " + sepp + " " + time);
deleteNotification.setVisibility(GlobalUserPreferences.enableDeleteNotifications && item.notification!=null && !item.inset ? View.VISIBLE : View.GONE);
if(item.hasVisibilityToggle){
visibility.setImageResource(item.status.spoilerRevealed ? R.drawable.ic_visibility_off : R.drawable.ic_visibility);
visibility.setContentDescription(item.parentFragment.getString(item.status.spoilerRevealed ? R.string.hide_content : R.string.reveal_content));
if (item.hasVisibilityToggle){
boolean disabled = !item.status.sensitiveRevealed ||
(!TextUtils.isEmpty(item.status.spoilerText) &&
!item.status.spoilerRevealed);
visibility.setEnabled(!disabled);
V.setVisibilityAnimated(visibility, disabled ? View.INVISIBLE : View.VISIBLE);
visibility.setContentDescription(item.parentFragment.getString(item.status.sensitiveRevealed ? R.string.spoiler_hide : R.string.spoiler_show));
if(Build.VERSION.SDK_INT>=Build.VERSION_CODES.O){
visibility.setTooltipText(visibility.getContentDescription());
}
} else {
visibility.setVisibility(View.GONE);
}
itemView.setPadding(itemView.getPaddingLeft(), itemView.getPaddingTop(), itemView.getPaddingRight(), item.needBottomPadding ? V.dp(16) : 0);
if(TextUtils.isEmpty(item.extraText)){
if (item.status != null) {
UiUtils.setExtraTextInfo(item.parentFragment.getContext(), extraText, item.status.visibility, item.status.localOnly);
UiUtils.setExtraTextInfo(item.parentFragment.getContext(), extraText, pronouns, item.status.visibility==StatusPrivacy.DIRECT, item.status.localOnly || item.status.visibility==StatusPrivacy.LOCAL, item.status.account);
}
}else{
extraText.setVisibility(View.VISIBLE);
extraText.setText(item.extraText);
}
more.setVisibility(item.inset || (item.notification != null && item.notification.report != null)
more.setVisibility(item.announcement != null || item.inset ||
(item.notification != null && item.notification.report != null)
? View.GONE : View.VISIBLE);
more.setOnClickListener(this::onMoreClick);
avatar.setClickable(!item.inset);
avatar.setContentDescription(item.parentFragment.getString(R.string.avatar_description, item.user.acct));
if(currentRelationshipRequest!=null){
@@ -350,20 +354,13 @@ public class HeaderStatusDisplayItem extends StatusDisplayItem{
}
relationship=null;
String desc;
if (item.announcement != null) {
if (unreadIndicator.getVisibility() == View.GONE) {
more.setAlpha(0f);
unreadIndicator.setAlpha(0f);
unreadIndicator.setVisibility(View.VISIBLE);
}
float alpha = item.announcement.read ? 0 : 1;
more.setImageResource(R.drawable.ic_fluent_checkmark_20_filled);
desc = item.parentFragment.getString(R.string.sk_mark_as_read);
more.animate().alpha(alpha);
unreadIndicator.animate().alpha(alpha);
more.setEnabled(!item.announcement.read);
more.setOnClickListener(v -> {
int vis = item.announcement.read ? View.GONE : View.VISIBLE;
V.setVisibilityAnimated(unreadIndicator, vis);
V.setVisibilityAnimated(markAsRead, vis);
markAsRead.setEnabled(!item.announcement.read);
markAsRead.setOnClickListener(v -> {
if (item.announcement.read) return;
new DismissAnnouncement(item.announcement.id).setCallback(new Callback<>() {
@Override
@@ -381,14 +378,9 @@ public class HeaderStatusDisplayItem extends StatusDisplayItem{
}).exec(item.accountID);
});
} else {
more.setImageResource(R.drawable.ic_fluent_more_vertical_20_filled);
desc = item.parentFragment.getString(R.string.more_options);
more.setOnClickListener(this::onMoreClick);
markAsRead.setVisibility(View.GONE);
}
more.setContentDescription(desc);
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) more.setTooltipText(desc);
if (item.status == null || !item.status.textExpandable) {
collapseBtn.setVisibility(View.GONE);
} else {
@@ -418,6 +410,10 @@ public class HeaderStatusDisplayItem extends StatusDisplayItem{
@Override
public void clearImage(int index){
if(index==0){
avatar.setImageResource(R.drawable.image_placeholder);
return;
}
setImage(index, null);
}
@@ -457,9 +453,11 @@ public class HeaderStatusDisplayItem extends StatusDisplayItem{
}
private void updateOptionsMenu(){
if (item.parentFragment.getActivity() == null) return;
if(item.parentFragment.getActivity()==null)
return;
if (item.announcement != null) return;
boolean hasMultipleAccounts = AccountSessionManager.getInstance().getLoggedInAccounts().size() > 1;
Account account=item.user;
Menu menu=optionsMenu.getMenu();
MenuItem openWithAccounts = menu.findItem(R.id.open_with_account);
@@ -474,7 +472,6 @@ public class HeaderStatusDisplayItem extends StatusDisplayItem{
openWithAccounts.setVisible(false);
}
Account account=item.user;
String username = account.getShortUsername();
boolean isOwnPost=AccountSessionManager.getInstance().isSelf(item.parentFragment.getAccountID(), account);
boolean isPostScheduled=item.scheduledStatus!=null;
@@ -492,9 +489,9 @@ public class HeaderStatusDisplayItem extends StatusDisplayItem{
MenuItem report=menu.findItem(R.id.report);
MenuItem follow=menu.findItem(R.id.follow);
MenuItem manageUserLists = menu.findItem(R.id.manage_user_lists);
/* disabled in megalodon: add/remove bookmark is already available through status footer
MenuItem bookmark=menu.findItem(R.id.bookmark);
bookmark.setVisible(false);
/* disabled in megalodon: add/remove bookmark is already available through status footer
if(item.status!=null){
bookmark.setVisible(true);
bookmark.setTitle(item.status.bookmarked ? R.string.remove_bookmark : R.string.add_bookmark);

View File

@@ -0,0 +1,41 @@
package org.joinmastodon.android.ui.displayitems;
import android.content.Context;
import android.view.ViewGroup;
import android.widget.Space;
import android.widget.TextView;
import org.joinmastodon.android.fragments.BaseStatusListFragment;
import me.grishka.appkit.utils.V;
public class InsetDummyStatusDisplayItem extends StatusDisplayItem {
private final boolean addMediaGridMargin;
public InsetDummyStatusDisplayItem(String parentID, BaseStatusListFragment<?> parentFragment, boolean addMediaGridMargin) {
super(parentID, parentFragment);
this.addMediaGridMargin = addMediaGridMargin;
}
@Override
public Type getType() {
return Type.DUMMY;
}
public static class Holder extends StatusDisplayItem.Holder<InsetDummyStatusDisplayItem> {
public Holder(Context context) {
super(new Space(context));
}
@Override
public void onBind(InsetDummyStatusDisplayItem item) {
// BetterItemAnimator appears not to handle InsetStatusItemDecoration's getItemOffsets
// correctly, causing removed inset views to jump while animating. i don't quite
// understand it, but this workaround appears to work.
// see InsetStatusItemDecoration#getItemOffsets
ViewGroup.MarginLayoutParams params = new ViewGroup.MarginLayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, 0);
params.setMargins(0, item.addMediaGridMargin ? V.dp(4) : 0, 0, V.dp(16));
itemView.setLayoutParams(params);
}
}
}

View File

@@ -8,11 +8,14 @@ import android.animation.AnimatorSet;
import android.animation.ObjectAnimator;
import android.app.Activity;
import android.graphics.Rect;
import android.graphics.drawable.ColorDrawable;
import android.graphics.drawable.Drawable;
import android.graphics.drawable.LayerDrawable;
import android.text.TextUtils;
import android.view.Gravity;
import android.view.View;
import android.view.ViewGroup;
import android.view.ViewPropertyAnimator;
import android.view.ViewTreeObserver;
import android.widget.FrameLayout;
import android.widget.ImageButton;
@@ -24,6 +27,7 @@ import org.joinmastodon.android.fragments.BaseStatusListFragment;
import org.joinmastodon.android.model.Attachment;
import org.joinmastodon.android.model.Status;
import org.joinmastodon.android.ui.PhotoLayoutHelper;
import org.joinmastodon.android.ui.drawables.SpoilerStripesDrawable;
import org.joinmastodon.android.ui.photoviewer.PhotoViewerHost;
import org.joinmastodon.android.ui.utils.MediaAttachmentViewController;
import org.joinmastodon.android.ui.utils.UiUtils;
@@ -39,6 +43,7 @@ import me.grishka.appkit.imageloader.ImageLoaderViewHolder;
import me.grishka.appkit.imageloader.requests.ImageLoaderRequest;
import me.grishka.appkit.imageloader.requests.UrlImageLoaderRequest;
import me.grishka.appkit.utils.CubicBezierInterpolator;
import me.grishka.appkit.utils.V;
public class MediaGridStatusDisplayItem extends StatusDisplayItem{
private static final String TAG="MediaGridDisplayItem";
@@ -48,6 +53,7 @@ public class MediaGridStatusDisplayItem extends StatusDisplayItem{
private final List<Attachment> attachments;
private final ArrayList<ImageLoaderRequest> requests=new ArrayList<>();
public final Status status;
public String sensitiveTitle;
public MediaGridStatusDisplayItem(String parentID, BaseStatusListFragment<?> parentFragment, PhotoLayoutHelper.TiledLayoutResult tiledLayout, List<Attachment> attachments, Status status){
super(parentID, parentFragment);
@@ -99,11 +105,15 @@ public class MediaGridStatusDisplayItem extends StatusDisplayItem{
private final ImageButton altTextClose;
private final TextView altText, noAltText;
private final View sensitiveOverlay;
private final LayerDrawable sensitiveOverlayBG;
private static final ColorDrawable drawableForWhenThereIsNoBlurhash=new ColorDrawable(0xffffffff);
// private final FrameLayout hideSensitiveButton;
private final TextView sensitiveText;
private int altTextIndex=-1;
private Animator altTextAnimator;
private boolean sizeUpdating = false;
public Holder(Activity activity, ViewGroup parent){
super(new FrameLayoutThatOnlyMeasuresFirstChild(activity));
wrapper=(FrameLayout)itemView;
@@ -124,10 +134,28 @@ public class MediaGridStatusDisplayItem extends StatusDisplayItem{
altText=findViewById(R.id.alt_text);
noAltText=findViewById(R.id.no_alt_text);
altTextClose.setOnClickListener(this::onAltTextCloseClick);
// megalodon: no sensitive hide button because the visibility toggle looks prettier imo
// hideSensitiveButton=(FrameLayout) activity.getLayoutInflater().inflate(R.layout.alt_text_badge, overlays, false);
// ((TextView) hideSensitiveButton.findViewById(R.id.alt_button)).setText(R.string.hide);
// overlays.addView(hideSensitiveButton, new FrameLayout.LayoutParams(ViewGroup.LayoutParams.WRAP_CONTENT, ViewGroup.LayoutParams.WRAP_CONTENT, Gravity.END | Gravity.TOP));
activity.getLayoutInflater().inflate(R.layout.overlay_image_sensitive, overlays);
sensitiveOverlay=findViewById(R.id.sensitive_overlay);
sensitiveOverlayBG=(LayerDrawable) sensitiveOverlay.getBackground().mutate();
sensitiveOverlayBG.setDrawableByLayerId(R.id.left_drawable, new SpoilerStripesDrawable(false));
sensitiveOverlayBG.setDrawableByLayerId(R.id.right_drawable, new SpoilerStripesDrawable(true));
sensitiveOverlay.setBackground(sensitiveOverlayBG);
sensitiveOverlay.setOnClickListener(v->revealSensitive());
// hideSensitiveButton.setOnClickListener(v->hideSensitive());
sensitiveText=findViewById(R.id.sensitive_text);
}
@Override
public void onBind(MediaGridStatusDisplayItem item){
wrapper.setPadding(0, 0, 0, 0); // item.inset ? 0 : V.dp(8));
if(altTextAnimator!=null)
altTextAnimator.cancel();
@@ -139,6 +167,7 @@ public class MediaGridStatusDisplayItem extends StatusDisplayItem{
controllers.clear();
int i=0;
if (!item.attachments.isEmpty()) updateBlurhashInSensitiveOverlay();
for(Attachment att:item.attachments){
MediaAttachmentViewController c=item.viewPool.obtain(switch(att.type){
case IMAGE -> GridItemType.PHOTO;
@@ -166,23 +195,36 @@ public class MediaGridStatusDisplayItem extends StatusDisplayItem{
noAltTextButton.setVisibility(View.VISIBLE);
altTextWrapper.setVisibility(View.GONE);
altTextIndex=-1;
if(!item.status.sensitiveRevealed){
sensitiveOverlay.setVisibility(View.VISIBLE);
layout.setVisibility(View.INVISIBLE);
}else{
sensitiveOverlay.setVisibility(View.GONE);
layout.setVisibility(View.VISIBLE);
}
// hideSensitiveButton.setVisibility(item.status.sensitive ? View.VISIBLE : View.GONE);
if(!TextUtils.isEmpty(item.sensitiveTitle))
sensitiveText.setText(item.sensitiveTitle);
else if (!item.status.sensitive)
sensitiveText.setText(R.string.media_hidden);
else
sensitiveText.setText(R.string.sensitive_content_explain);
}
@Override
public void setImage(int index, Drawable drawable){
Rect bounds=drawable.getBounds();
drawable.setBounds(bounds.left, bounds.top, bounds.left+drawable.getIntrinsicWidth(), bounds.top+drawable.getIntrinsicHeight());
if(item.attachments.get(index).meta==null){
Rect bounds=drawable.getBounds();
drawable.setBounds(bounds.left, bounds.top, bounds.left+drawable.getIntrinsicWidth(), bounds.top+drawable.getIntrinsicHeight());
Attachment.Metadata metadata = new Attachment.Metadata();
metadata.width=drawable.getIntrinsicWidth();
metadata.height=drawable.getIntrinsicHeight();
item.attachments.get(index).meta=metadata;
item.tiledLayout=PhotoLayoutHelper.processThumbs(item.attachments);
sizeUpdating = true;
item.parentFragment.onImageUpdated(this, index);
UiUtils.beginLayoutTransition((ViewGroup) itemView);
rebind();
}
controllers.get(index).setImage(drawable);
}
@@ -193,16 +235,13 @@ public class MediaGridStatusDisplayItem extends StatusDisplayItem{
private void onViewClick(View v){
int index=(Integer)v.getTag();
if(!item.status.spoilerRevealed){
item.parentFragment.onRevealSpoilerClick(this);
}else if(item.parentFragment instanceof PhotoViewerHost){
((PhotoViewerHost) item.parentFragment).openPhotoViewer(item.parentID, item.status, index, this);
}
((PhotoViewerHost) item.parentFragment).openPhotoViewer(item.parentID, item.status, index, this);
}
private void onAltTextClick(View v){
if(altTextAnimator!=null)
altTextAnimator.cancel();
// V.setVisibilityAnimated(hideSensitiveButton, View.GONE);
v.setVisibility(View.INVISIBLE);
int index=(Integer)v.getTag();
altTextIndex=index;
@@ -245,6 +284,9 @@ public class MediaGridStatusDisplayItem extends StatusDisplayItem{
if(c.btnsWrap!=null && c.btnsWrap!=v){
anims.add(ObjectAnimator.ofFloat(c.btnsWrap, View.ALPHA, 1, 0).setDuration(150));
}
if (c.extraBadge != null) {
anims.add(ObjectAnimator.ofFloat(c.extraBadge, View.ALPHA, 1, 0).setDuration(150));
}
}
AnimatorSet set=new AnimatorSet();
@@ -258,6 +300,7 @@ public class MediaGridStatusDisplayItem extends StatusDisplayItem{
if(c.btnsWrap!=null){
c.btnsWrap.setVisibility(View.INVISIBLE);
}
if (c.extraBadge != null) c.extraBadge.setVisibility(View.INVISIBLE);
}
}
});
@@ -273,6 +316,7 @@ public class MediaGridStatusDisplayItem extends StatusDisplayItem{
if(altTextAnimator!=null)
altTextAnimator.cancel();
// V.setVisibilityAnimated(hideSensitiveButton, item.status.sensitive ? View.VISIBLE : View.GONE);
View btn=controllers.get(altTextIndex).btnsWrap;
int i=0;
for(MediaAttachmentViewController c:controllers){
@@ -281,6 +325,7 @@ public class MediaGridStatusDisplayItem extends StatusDisplayItem{
&& c.btnsWrap!=btn
&& ((hasAltText && showAltIndicator) || (!hasAltText && showNoAltIndicator))
) c.btnsWrap.setVisibility(View.VISIBLE);
if (c.extraBadge != null) c.extraBadge.setVisibility(View.VISIBLE);
i++;
}
@@ -305,9 +350,12 @@ public class MediaGridStatusDisplayItem extends StatusDisplayItem{
a.setDuration(300);
for(MediaAttachmentViewController c:controllers){
// if(c.btnsWrap!=null && c.btnsWrap!=btn){
if(c.btnsWrap!=null && c.btnsWrap!=btn){
anims.add(ObjectAnimator.ofFloat(c.btnsWrap, View.ALPHA, 1).setDuration(150));
// }
}
if (c.extraBadge != null) {
anims.add(ObjectAnimator.ofFloat(c.extraBadge, View.ALPHA, 1).setDuration(150));
}
}
AnimatorSet set=new AnimatorSet();
@@ -319,18 +367,13 @@ public class MediaGridStatusDisplayItem extends StatusDisplayItem{
altTextAnimator=null;
altTextWrapper.setVisibility(View.GONE);
btn.setVisibility(View.VISIBLE);
btn.setAlpha(1);
}
});
altTextAnimator=set;
set.start();
}
public void setRevealed(boolean revealed){
for(MediaAttachmentViewController c:controllers){
c.setRevealed(revealed);
}
}
public MediaAttachmentViewController getViewController(int index){
return controllers.get(index);
}
@@ -340,12 +383,35 @@ public class MediaGridStatusDisplayItem extends StatusDisplayItem{
wrapper.setClipChildren(clip);
}
public boolean isSizeUpdating() {
return sizeUpdating;
private void updateBlurhashInSensitiveOverlay(){
Drawable d = item.attachments.get(0).blurhashPlaceholder;
sensitiveOverlayBG.setDrawableByLayerId(R.id.blurhash, d==null ? drawableForWhenThereIsNoBlurhash : d.mutate());
sensitiveOverlay.setBackground(sensitiveOverlayBG);
}
public void sizeUpdated() {
sizeUpdating = false;
public void revealSensitive(){
if(item.status.sensitiveRevealed)
return;
item.status.sensitiveRevealed=true;
V.setVisibilityAnimated(sensitiveOverlay, View.GONE);
layout.setVisibility(View.VISIBLE);
item.parentFragment.onSensitiveRevealed(this);
}
public void hideSensitive(){
if(!item.status.sensitiveRevealed)
return;
updateBlurhashInSensitiveOverlay();
item.status.sensitiveRevealed=false;
V.setVisibilityAnimated(sensitiveOverlay, View.VISIBLE, ()->layout.setVisibility(View.INVISIBLE));
}
public MediaGridLayout getLayout(){
return layout;
}
public View getSensitiveOverlay(){
return sensitiveOverlay;
}
}
}

View File

@@ -0,0 +1,184 @@
package org.joinmastodon.android.ui.displayitems;
import static org.joinmastodon.android.MastodonApp.context;
import static org.joinmastodon.android.model.Notification.Type.PLEROMA_EMOJI_REACTION;
import static org.joinmastodon.android.ui.utils.UiUtils.generateFormattedString;
import android.annotation.SuppressLint;
import android.app.Activity;
import android.content.res.ColorStateList;
import android.graphics.drawable.Drawable;
import android.os.Bundle;
import android.text.Html;
import android.text.SpannableStringBuilder;
import android.text.TextUtils;
import android.util.TypedValue;
import android.view.View;
import android.view.ViewGroup;
import android.widget.ImageView;
import android.widget.TextView;
import org.joinmastodon.android.GlobalUserPreferences;
import org.joinmastodon.android.R;
import org.joinmastodon.android.fragments.BaseStatusListFragment;
import org.joinmastodon.android.fragments.ProfileFragment;
import org.joinmastodon.android.model.Emoji;
import org.joinmastodon.android.model.Notification;
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;
import org.parceler.Parcels;
import java.util.Collections;
import me.grishka.appkit.Nav;
import me.grishka.appkit.imageloader.ImageLoaderViewHolder;
import me.grishka.appkit.imageloader.requests.ImageLoaderRequest;
import me.grishka.appkit.imageloader.requests.UrlImageLoaderRequest;
import me.grishka.appkit.utils.V;
public class NotificationHeaderStatusDisplayItem extends StatusDisplayItem{
public final Notification notification;
private ImageLoaderRequest avaRequest;
private final String accountID;
private final CustomEmojiHelper emojiHelper=new CustomEmojiHelper();
private final CharSequence text;
public NotificationHeaderStatusDisplayItem(String parentID, BaseStatusListFragment parentFragment, Notification notification, String accountID){
super(parentID, parentFragment);
this.notification=notification;
this.accountID=accountID;
if(notification.type==Notification.Type.POLL){
text=parentFragment.getString(R.string.poll_ended);
}else{
avaRequest=new UrlImageLoaderRequest(GlobalUserPreferences.playGifs ? notification.account.avatar : notification.account.avatarStatic, V.dp(50), V.dp(50));
SpannableStringBuilder parsedName=new SpannableStringBuilder(notification.account.displayName);
HtmlParser.parseCustomEmoji(parsedName, notification.account.emojis);
String str = parentFragment.getString(switch(notification.type){
case FOLLOW -> R.string.user_followed_you;
case FOLLOW_REQUEST -> R.string.user_sent_follow_request;
case REBLOG -> R.string.notification_boosted;
case FAVORITE -> R.string.user_favorited;
case POLL -> R.string.poll_ended;
case UPDATE -> R.string.sk_post_edited;
case SIGN_UP -> R.string.sk_signed_up;
case REPORT -> R.string.sk_reported;
case REACTION, PLEROMA_EMOJI_REACTION ->
!TextUtils.isEmpty(notification.emoji) ? R.string.sk_reacted_with : R.string.sk_reacted;
default -> throw new IllegalStateException("Unexpected value: "+notification.type);
});
if (!TextUtils.isEmpty(notification.emoji)) {
SpannableStringBuilder emoji = new SpannableStringBuilder(notification.emoji);
if (!TextUtils.isEmpty(notification.emojiUrl)) {
HtmlParser.parseCustomEmoji(emoji, Collections.singletonList(new Emoji(
notification.emoji, notification.emojiUrl, notification.emojiUrl
)));
}
this.text = generateFormattedString(str, parsedName, emoji);
} else {
this.text = generateFormattedString(str, parsedName);
}
emojiHelper.setText(text);
}
}
@Override
public Type getType(){
return Type.NOTIFICATION_HEADER;
}
@Override
public int getImageCount(){
return 1+emojiHelper.getImageCount();
}
@Override
public ImageLoaderRequest getImageRequest(int index){
if(index>0){
return emojiHelper.getImageRequest(index-1);
}
return avaRequest;
}
public static class Holder extends StatusDisplayItem.Holder<NotificationHeaderStatusDisplayItem> implements ImageLoaderViewHolder{
private final ImageView icon, avatar;
private final TextView text;
private final int selectableItemBackground;
public Holder(Activity activity, ViewGroup parent){
super(activity, R.layout.display_item_notification_header, parent);
icon=findViewById(R.id.icon);
avatar=findViewById(R.id.avatar);
text=findViewById(R.id.text);
avatar.setOutlineProvider(OutlineProviders.roundedRect(8));
avatar.setClipToOutline(true);
itemView.setOnClickListener(this::onItemClick);
TypedValue outValue = new TypedValue();
context.getTheme().resolveAttribute(android.R.attr.selectableItemBackground, outValue, true);
selectableItemBackground = outValue.resourceId;
}
@Override
public void setImage(int index, Drawable image){
if(index==0){
avatar.setImageDrawable(image);
}else{
item.emojiHelper.setImageDrawable(index-1, image);
text.invalidate();
}
}
@Override
public void clearImage(int index){
if(index==0)
avatar.setImageResource(R.drawable.image_placeholder);
else
ImageLoaderViewHolder.super.clearImage(index);
}
@SuppressLint("ResourceType")
@Override
public void onBind(NotificationHeaderStatusDisplayItem item){
text.setText(item.text);
avatar.setVisibility(item.notification.type==Notification.Type.POLL ? View.GONE : View.VISIBLE);
icon.setImageResource(switch(item.notification.type){
case FAVORITE -> R.drawable.ic_fluent_star_24_filled;
case REBLOG -> R.drawable.ic_fluent_arrow_repeat_all_24_filled;
case FOLLOW, FOLLOW_REQUEST -> R.drawable.ic_fluent_person_add_24_filled;
case POLL -> R.drawable.ic_fluent_poll_24_filled;
case REPORT -> R.drawable.ic_fluent_warning_24_filled;
case SIGN_UP -> R.drawable.ic_fluent_person_available_24_filled;
case UPDATE -> R.drawable.ic_fluent_edit_24_filled;
case REACTION, PLEROMA_EMOJI_REACTION -> R.drawable.ic_fluent_add_24_filled;
default -> throw new IllegalStateException("Unexpected value: "+item.notification.type);
});
icon.setImageTintList(ColorStateList.valueOf(UiUtils.getThemeColor(item.parentFragment.getActivity(), switch(item.notification.type){
case FAVORITE -> R.attr.colorFavorite;
case REBLOG -> R.attr.colorBoost;
case POLL -> R.attr.colorPoll;
default -> android.R.attr.colorAccent;
})));
itemView.setBackgroundResource(item.notification.type != Notification.Type.POLL
&& item.notification.type != Notification.Type.REPORT ?
selectableItemBackground : 0);
itemView.setClickable(item.notification.type != Notification.Type.POLL);
}
public void onItemClick(View v) {
if (item.notification.type == Notification.Type.REPORT) {
UiUtils.showFragmentForNotification(item.parentFragment.getContext(), item.notification, item.accountID, null);
return;
}
Bundle args=new Bundle();
args.putString("account", item.accountID);
args.putParcelable("profileAccount", Parcels.wrap(item.notification.account));
Nav.go(item.parentFragment.getActivity(), ProfileFragment.class, args);
}
}
}

View File

@@ -38,10 +38,12 @@ public class PollFooterStatusDisplayItem extends StatusDisplayItem{
@Override
public void onBind(PollFooterStatusDisplayItem item){
String text=item.parentFragment.getResources().getQuantityString(R.plurals.x_voters, item.poll.votersCount, item.poll.votersCount);
String text=item.parentFragment.getResources().getQuantityString(R.plurals.x_votes, item.poll.votesCount, item.poll.votesCount);
String sep=item.parentFragment.getString(R.string.sk_separator);
if(item.poll.expiresAt!=null && !item.poll.isExpired()){
text+=" "+sep+" "+UiUtils.formatTimeLeft(itemView.getContext(), item.poll.expiresAt);
if(item.poll.multiple)
text+=" "+sep+" "+item.parentFragment.getString(R.string.poll_multiple_choice);
}else if(item.poll.isExpired()){
text+=" "+sep+" "+item.parentFragment.getString(R.string.poll_closed);
}

View File

@@ -11,8 +11,10 @@ import android.widget.TextView;
import org.joinmastodon.android.R;
import org.joinmastodon.android.fragments.BaseStatusListFragment;
import org.joinmastodon.android.model.Poll;
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;
import java.util.Locale;
@@ -26,11 +28,13 @@ public class PollOptionStatusDisplayItem extends StatusDisplayItem{
private boolean showResults;
private float votesFraction; // 0..1
private boolean isMostVoted;
private final int optionIndex;
public final Poll poll;
public PollOptionStatusDisplayItem(String parentID, Poll poll, Poll.Option option, BaseStatusListFragment parentFragment){
public PollOptionStatusDisplayItem(String parentID, Poll poll, int optionIndex, BaseStatusListFragment parentFragment){
super(parentID, parentFragment);
this.option=option;
this.optionIndex=optionIndex;
option=poll.options.get(optionIndex);
this.poll=poll;
text=HtmlParser.parseCustomEmoji(option.title, poll.emojis);
emojiHelper.setText(text);
@@ -64,7 +68,7 @@ public class PollOptionStatusDisplayItem extends StatusDisplayItem{
private final TextView text, percent;
private final View button;
private final ImageView icon;
private final Drawable progressBg;
private final Drawable progressBg, progressBgInset;
public Holder(Activity activity, ViewGroup parent){
super(activity, R.layout.display_item_poll_option, parent);
@@ -73,7 +77,10 @@ public class PollOptionStatusDisplayItem extends StatusDisplayItem{
icon=findViewById(R.id.icon);
button=findViewById(R.id.button);
progressBg=activity.getResources().getDrawable(R.drawable.bg_poll_option_voted, activity.getTheme()).mutate();
progressBgInset=activity.getResources().getDrawable(R.drawable.bg_poll_option_voted_inset, activity.getTheme()).mutate();
itemView.setOnClickListener(this::onButtonClick);
button.setOutlineProvider(OutlineProviders.roundedRect(24));
button.setClipToOutline(true);
}
@Override
@@ -86,17 +93,21 @@ public class PollOptionStatusDisplayItem extends StatusDisplayItem{
item.showResults ? R.drawable.ic_poll_option_button : R.drawable.ic_fluent_radio_button_24_selector
));
if(item.showResults){
progressBg.setLevel(Math.round(10000f*item.votesFraction));
button.setBackground(progressBg);
itemView.setSelected(item.isMostVoted);
icon.setSelected(item.poll.ownVotes.contains(item.poll.options.indexOf(item.option)));
icon.setVisibility(item.poll.voted && item.poll.ownVotes.isEmpty() ? View.GONE : View.VISIBLE);
Drawable bg=item.inset ? progressBgInset : progressBg;
bg.setLevel(Math.round(10000f*item.votesFraction));
button.setBackground(bg);
itemView.setSelected(item.poll.ownVotes!=null && item.poll.ownVotes.contains(item.optionIndex));
percent.setText(String.format(Locale.getDefault(), "%d%%", Math.round(item.votesFraction*100f)));
}else{
itemView.setSelected(item.poll.selectedOptions!=null && item.poll.selectedOptions.contains(item.option));
button.setBackgroundResource(R.drawable.bg_poll_option_clickable);
icon.setSelected(itemView.isSelected());
icon.setVisibility(View.VISIBLE);
button.setBackgroundResource(item.inset ? R.drawable.bg_poll_option_clickable_inset : R.drawable.bg_poll_option_clickable);
}
if(item.inset){
text.setTextColor(itemView.getContext().getColorStateList(R.color.poll_option_text_inset));
percent.setTextColor(itemView.getContext().getColorStateList(R.color.poll_option_text_inset));
}else{
text.setTextColor(UiUtils.getThemeColor(itemView.getContext(), android.R.attr.textColorPrimary));
percent.setTextColor(UiUtils.getThemeColor(itemView.getContext(), R.attr.colorM3OnSecondaryContainer));
}
}

View File

@@ -106,7 +106,7 @@ public class ReblogOrReplyLineStatusDisplayItem extends StatusDisplayItem{
text=findViewById(R.id.text);
extraText=findViewById(R.id.extra_text);
separator=findViewById(R.id.separator);
if (GlobalUserPreferences.replyLineAboveHeader && GlobalUserPreferences.compactReblogReplyLine) {
if (GlobalUserPreferences.compactReblogReplyLine) {
parent.addOnLayoutChangeListener((v, l, t, right, b, ol, ot, oldRight, ob) -> {
if (right != oldRight) layoutLine();
});
@@ -151,9 +151,7 @@ public class ReblogOrReplyLineStatusDisplayItem extends StatusDisplayItem{
private void layoutLine() {
// layout line only if above header, compact and has extra
if (!GlobalUserPreferences.replyLineAboveHeader
|| !GlobalUserPreferences.compactReblogReplyLine
|| item.extra == null) return;
if (!GlobalUserPreferences.compactReblogReplyLine || item.extra == null) return;
itemView.measure(
View.MeasureSpec.makeMeasureSpec(parent.getWidth(), View.MeasureSpec.EXACTLY),
View.MeasureSpec.UNSPECIFIED);

View File

@@ -0,0 +1,55 @@
package org.joinmastodon.android.ui.displayitems;
import android.content.Context;
import android.view.View;
import android.view.ViewGroup;
import android.widget.Button;
import android.widget.TextView;
import org.joinmastodon.android.R;
import org.joinmastodon.android.fragments.BaseStatusListFragment;
public class SectionHeaderStatusDisplayItem extends StatusDisplayItem{
public final String title, buttonText;
public final Runnable onButtonClick;
public SectionHeaderStatusDisplayItem(BaseStatusListFragment parentFragment, String title, String buttonText, Runnable onButtonClick){
super("", parentFragment);
this.title=title;
this.buttonText=buttonText;
this.onButtonClick=onButtonClick;
}
@Override
public Type getType(){
return Type.SECTION_HEADER;
}
public static class Holder extends StatusDisplayItem.Holder<SectionHeaderStatusDisplayItem>{
private final TextView title;
private final Button actionBtn;
public Holder(Context context, ViewGroup parent){
super(context, R.layout.display_item_section_header, parent);
title=findViewById(R.id.title);
actionBtn=findViewById(R.id.action_btn);
actionBtn.setOnClickListener(v->item.onButtonClick.run());
}
@Override
public void onBind(SectionHeaderStatusDisplayItem item){
title.setText(item.title);
if(item.onButtonClick!=null){
actionBtn.setVisibility(View.VISIBLE);
actionBtn.setText(item.buttonText);
}else{
actionBtn.setVisibility(View.GONE);
}
}
@Override
public boolean isEnabled(){
return false;
}
}
}

View File

@@ -0,0 +1,110 @@
package org.joinmastodon.android.ui.displayitems;
import android.content.Context;
import android.graphics.drawable.Drawable;
import android.graphics.drawable.LayerDrawable;
import android.text.TextUtils;
import android.view.View;
import android.view.ViewGroup;
import android.widget.TextView;
import org.joinmastodon.android.GlobalUserPreferences;
import org.joinmastodon.android.R;
import org.joinmastodon.android.fragments.BaseStatusListFragment;
import org.joinmastodon.android.model.Status;
import org.joinmastodon.android.ui.OutlineProviders;
import org.joinmastodon.android.ui.drawables.SpoilerStripesDrawable;
import org.joinmastodon.android.ui.drawables.TiledDrawable;
import org.joinmastodon.android.ui.text.HtmlParser;
import org.joinmastodon.android.ui.utils.CustomEmojiHelper;
import java.util.ArrayList;
import me.grishka.appkit.imageloader.ImageLoaderViewHolder;
import me.grishka.appkit.imageloader.requests.ImageLoaderRequest;
public class SpoilerStatusDisplayItem extends StatusDisplayItem{
public final Status status;
public final ArrayList<StatusDisplayItem> contentItems=new ArrayList<>();
private final CharSequence parsedTitle;
private final CustomEmojiHelper emojiHelper;
private final Type type;
public SpoilerStatusDisplayItem(String parentID, BaseStatusListFragment<?> parentFragment, String title, Status statusForContent, Type type){
super(parentID, parentFragment);
this.status=statusForContent;
this.type=type;
if(TextUtils.isEmpty(title)){
parsedTitle=HtmlParser.parseCustomEmoji(statusForContent.spoilerText, statusForContent.emojis);
emojiHelper=new CustomEmojiHelper();
emojiHelper.setText(parsedTitle);
}else{
parsedTitle=title;
emojiHelper=null;
}
}
@Override
public int getImageCount(){
return emojiHelper==null ? 0 : emojiHelper.getImageCount();
}
@Override
public ImageLoaderRequest getImageRequest(int index){
return emojiHelper.getImageRequest(index);
}
@Override
public Type getType(){
return type;
}
public static class Holder extends StatusDisplayItem.Holder<SpoilerStatusDisplayItem> implements ImageLoaderViewHolder{
private final TextView title, action;
private final View button;
public Holder(Context context, ViewGroup parent, Type type){
super(context, R.layout.display_item_spoiler, parent);
title=findViewById(R.id.spoiler_title);
action=findViewById(R.id.spoiler_action);
button=findViewById(R.id.spoiler_button);
button.setOutlineProvider(OutlineProviders.roundedRect(8));
button.setClipToOutline(true);
LayerDrawable spoilerBg=(LayerDrawable) button.getBackground().mutate();
if(type==Type.SPOILER){
spoilerBg.setDrawableByLayerId(R.id.left_drawable, new SpoilerStripesDrawable(true));
spoilerBg.setDrawableByLayerId(R.id.right_drawable, new SpoilerStripesDrawable(false));
}else if(type==Type.FILTER_SPOILER){
Drawable texture=context.getDrawable(R.drawable.filter_banner_stripe_texture);
spoilerBg.setDrawableByLayerId(R.id.left_drawable, new TiledDrawable(texture));
spoilerBg.setDrawableByLayerId(R.id.right_drawable, new TiledDrawable(texture));
}
button.setBackground(spoilerBg);
button.setOnClickListener(v->item.parentFragment.onRevealSpoilerClick(this));
}
@Override
public void onBind(SpoilerStatusDisplayItem item){
title.setText(item.parsedTitle);
action.setText(item.status.spoilerRevealed ? R.string.spoiler_hide : R.string.sk_spoiler_show);
itemView.setPadding(
itemView.getPaddingLeft(),
itemView.getPaddingTop(),
itemView.getPaddingRight(),
item.inset || GlobalUserPreferences.spectatorMode ? itemView.getPaddingTop() : 0
);
}
@Override
public void setImage(int index, Drawable image){
item.emojiHelper.setImageDrawable(index, image);
title.invalidate();
}
@Override
public void clearImage(int index){
setImage(index, null);
}
}
}

View File

@@ -1,9 +1,10 @@
package org.joinmastodon.android.ui.displayitems;
import android.app.Activity;
import android.app.Fragment;
import android.content.Context;
import android.graphics.drawable.ColorDrawable;
import android.os.Bundle;
import android.text.SpannableStringBuilder;
import android.text.TextUtils;
import android.view.View;
import android.view.ViewGroup;
@@ -20,14 +21,17 @@ import org.joinmastodon.android.fragments.ThreadFragment;
import org.joinmastodon.android.model.Account;
import org.joinmastodon.android.model.Attachment;
import org.joinmastodon.android.model.DisplayItemsParent;
import org.joinmastodon.android.model.Filter;
import org.joinmastodon.android.model.LegacyFilter;
import org.joinmastodon.android.model.FilterAction;
import org.joinmastodon.android.model.FilterContext;
import org.joinmastodon.android.model.FilterResult;
import org.joinmastodon.android.model.Notification;
import org.joinmastodon.android.model.Poll;
import org.joinmastodon.android.model.ScheduledStatus;
import org.joinmastodon.android.model.Status;
import org.joinmastodon.android.ui.PhotoLayoutHelper;
import org.joinmastodon.android.ui.text.HtmlParser;
import org.joinmastodon.android.ui.utils.UiUtils;
import org.joinmastodon.android.ui.viewholders.AccountViewHolder;
import org.joinmastodon.android.utils.StatusFilterPredicate;
import org.parceler.Parcels;
@@ -53,6 +57,13 @@ public abstract class StatusDisplayItem{
isMainStatus = true,
isDirectDescendant = false;
public static final int FLAG_INSET=1;
public static final int FLAG_NO_FOOTER=1 << 1;
public static final int FLAG_CHECKABLE=1 << 2;
public static final int FLAG_MEDIA_FORCE_HIDDEN=1 << 3;
public static final int FLAG_NO_HEADER=1 << 4;
public static final int FLAG_NO_TRANSLATE=1 << 5;
public void setAncestryInfo(
boolean hasDescendantNeighbor,
boolean hasAncestoringNeighbor,
@@ -80,9 +91,10 @@ public abstract class StatusDisplayItem{
return null;
}
public static BindableViewHolder<? extends StatusDisplayItem> createViewHolder(Type type, Activity activity, ViewGroup parent){
public static BindableViewHolder<? extends StatusDisplayItem> createViewHolder(Type type, Activity activity, ViewGroup parent, Fragment parentFragment){
return switch(type){
case HEADER -> new HeaderStatusDisplayItem.Holder(activity, parent);
case HEADER_CHECKABLE -> new CheckableHeaderStatusDisplayItem.Holder(activity, parent);
case REBLOG_OR_REPLY_LINE -> new ReblogOrReplyLineStatusDisplayItem.Holder(activity, parent);
case TEXT -> new TextStatusDisplayItem.Holder(activity, parent);
case AUDIO -> new AudioStatusDisplayItem.Holder(activity, parent);
@@ -91,18 +103,29 @@ public abstract class StatusDisplayItem{
case CARD -> new LinkCardStatusDisplayItem.Holder(activity, parent);
case FOOTER -> new FooterStatusDisplayItem.Holder(activity, parent);
case ACCOUNT_CARD -> new AccountCardStatusDisplayItem.Holder(activity, parent);
case ACCOUNT -> new AccountStatusDisplayItem.Holder(activity, parent);
case ACCOUNT -> new AccountStatusDisplayItem.Holder(new AccountViewHolder(parentFragment, parent, null));
case HASHTAG -> new HashtagStatusDisplayItem.Holder(activity, parent);
case GAP -> new GapStatusDisplayItem.Holder(activity, parent);
case EXTENDED_FOOTER -> new ExtendedFooterStatusDisplayItem.Holder(activity, parent);
case MEDIA_GRID -> new MediaGridStatusDisplayItem.Holder(activity, parent);
case WARNING -> new WarningFilteredStatusDisplayItem.Holder(activity, parent);
case FILE -> new FileStatusDisplayItem.Holder(activity, parent);
case SPOILER, FILTER_SPOILER -> new SpoilerStatusDisplayItem.Holder(activity, parent, type);
case SECTION_HEADER -> null; // new SectionHeaderStatusDisplayItem.Holder(activity, parent);
case NOTIFICATION_HEADER -> new NotificationHeaderStatusDisplayItem.Holder(activity, parent);
case DUMMY -> new InsetDummyStatusDisplayItem.Holder(activity);
};
}
public static ArrayList<StatusDisplayItem> buildItems(BaseStatusListFragment<?> fragment, Status status, String accountID, DisplayItemsParent parentObject, Map<String, Account> knownAccounts, boolean inset, boolean addFooter, Notification notification, Filter.FilterContext filterContext){
return buildItems(fragment, status, accountID, parentObject, knownAccounts, inset, addFooter, notification, false, filterContext);
public static ArrayList<StatusDisplayItem> buildItems(BaseStatusListFragment<?> fragment, Status status, String accountID, DisplayItemsParent parentObject, Map<String, Account> knownAccounts, boolean inset, boolean addFooter, boolean disableTranslate, FilterContext filterContext) {
int flags=0;
if(inset)
flags|=FLAG_INSET;
if(!addFooter)
flags|=FLAG_NO_FOOTER;
if (disableTranslate)
flags|=FLAG_NO_TRANSLATE;
return buildItems(fragment, status, accountID, parentObject, knownAccounts, filterContext, flags);
}
public static ReblogOrReplyLineStatusDisplayItem buildReplyLine(BaseStatusListFragment<?> fragment, Status status, String accountID, DisplayItemsParent parent, Account account, boolean threadReply) {
@@ -120,70 +143,91 @@ public abstract class StatusDisplayItem{
);
}
public static ArrayList<StatusDisplayItem> buildItems(BaseStatusListFragment<?> fragment, Status status, String accountID, DisplayItemsParent parentObject, Map<String, Account> knownAccounts, boolean inset, boolean addFooter, Notification notification, boolean disableTranslate, Filter.FilterContext filterContext){
public static ArrayList<StatusDisplayItem> buildItems(BaseStatusListFragment<?> fragment, Status status, String accountID, DisplayItemsParent parentObject, Map<String, Account> knownAccounts, FilterContext filterContext, int flags){
String parentID=parentObject.getID();
ArrayList<StatusDisplayItem> items=new ArrayList<>();
Status statusForContent=status.getContentStatus();
Bundle args=new Bundle();
args.putString("account", accountID);
ScheduledStatus scheduledStatus = parentObject instanceof ScheduledStatus ? (ScheduledStatus) parentObject : null;
ReblogOrReplyLineStatusDisplayItem replyLine = null;
boolean threadReply = statusForContent.inReplyToAccountId != null &&
statusForContent.inReplyToAccountId.equals(statusForContent.account.id);
HeaderStatusDisplayItem header=null;
boolean hideCounts=!AccountSessionManager.get(accountID).getLocalPreferences().showInteractionCounts;
if(statusForContent.inReplyToAccountId!=null && !(threadReply && fragment instanceof ThreadFragment)){
Account account = knownAccounts.get(statusForContent.inReplyToAccountId);
replyLine = buildReplyLine(fragment, status, accountID, parentObject, account, threadReply);
if((flags & FLAG_NO_HEADER)==0){
ReblogOrReplyLineStatusDisplayItem replyLine = null;
boolean threadReply = statusForContent.inReplyToAccountId != null &&
statusForContent.inReplyToAccountId.equals(statusForContent.account.id);
if(statusForContent.inReplyToAccountId!=null && !(threadReply && fragment instanceof ThreadFragment)){
Account account = knownAccounts.get(statusForContent.inReplyToAccountId);
replyLine = buildReplyLine(fragment, status, accountID, parentObject, account, threadReply);
}
if(status.reblog!=null){
boolean isOwnPost = AccountSessionManager.getInstance().isSelf(fragment.getAccountID(), status.account);
String fullText = fragment.getString(R.string.user_boosted, status.account.displayName);
String text = GlobalUserPreferences.compactReblogReplyLine && replyLine != null ? status.account.displayName : fullText;
items.add(new ReblogOrReplyLineStatusDisplayItem(parentID, fragment, text, status.account.emojis, R.drawable.ic_fluent_arrow_repeat_all_20sp_filled, isOwnPost ? status.visibility : null, i->{
args.putParcelable("profileAccount", Parcels.wrap(status.account));
Nav.go(fragment.getActivity(), ProfileFragment.class, args);
}, fullText));
} else if (!(status.tags.isEmpty() ||
fragment instanceof HashtagTimelineFragment ||
fragment instanceof ListTimelineFragment
) && fragment.getParentFragment() instanceof HomeTabFragment home) {
home.getHashtags().stream()
.filter(followed -> status.tags.stream()
.anyMatch(hashtag -> followed.name.equalsIgnoreCase(hashtag.name)))
.findAny()
// post contains a hashtag the user is following
.ifPresent(hashtag -> items.add(new ReblogOrReplyLineStatusDisplayItem(
parentID, fragment, hashtag.name, List.of(),
R.drawable.ic_fluent_number_symbol_20sp_filled, null,
i -> {
args.putString("hashtag", hashtag.name);
Nav.go(fragment.getActivity(), HashtagTimelineFragment.class, args);
}
)));
}
if (replyLine != null) {
Optional<ReblogOrReplyLineStatusDisplayItem> primaryLine = items.stream()
.filter(i -> i instanceof ReblogOrReplyLineStatusDisplayItem)
.map(ReblogOrReplyLineStatusDisplayItem.class::cast)
.findFirst();
if (primaryLine.isPresent() && GlobalUserPreferences.compactReblogReplyLine) {
primaryLine.get().extra = replyLine;
} else {
items.add(replyLine);
}
}
if((flags & FLAG_CHECKABLE)!=0)
items.add(header=new CheckableHeaderStatusDisplayItem(parentID, statusForContent.account, statusForContent.createdAt, fragment, accountID, statusForContent, null));
else
items.add(header=new HeaderStatusDisplayItem(parentID, statusForContent.account, statusForContent.createdAt, fragment, accountID, statusForContent, null, null, scheduledStatus));
}
if(status.reblog!=null){
boolean isOwnPost = AccountSessionManager.getInstance().isSelf(fragment.getAccountID(), status.account);
String fullText = fragment.getString(R.string.user_boosted, status.account.displayName);
String text = GlobalUserPreferences.compactReblogReplyLine && replyLine != null ? status.account.displayName : fullText;
items.add(new ReblogOrReplyLineStatusDisplayItem(parentID, fragment, text, status.account.emojis, R.drawable.ic_fluent_arrow_repeat_all_20sp_filled, isOwnPost ? status.visibility : null, i->{
args.putParcelable("profileAccount", Parcels.wrap(status.account));
Nav.go(fragment.getActivity(), ProfileFragment.class, args);
}, fullText));
} else if (!(status.tags.isEmpty() ||
fragment instanceof HashtagTimelineFragment ||
fragment instanceof ListTimelineFragment
) && fragment.getParentFragment() instanceof HomeTabFragment home) {
home.getHashtags().stream()
.filter(followed -> status.tags.stream()
.anyMatch(hashtag -> followed.name.equalsIgnoreCase(hashtag.name)))
.findAny()
// post contains a hashtag the user is following
.ifPresent(hashtag -> items.add(new ReblogOrReplyLineStatusDisplayItem(
parentID, fragment, hashtag.name, List.of(),
R.drawable.ic_fluent_number_symbol_20sp_filled, null,
i -> {
args.putString("hashtag", hashtag.name);
Nav.go(fragment.getActivity(), HashtagTimelineFragment.class, args);
}
)));
}
if (replyLine != null && GlobalUserPreferences.replyLineAboveHeader) {
Optional<ReblogOrReplyLineStatusDisplayItem> primaryLine = items.stream()
.filter(i -> i instanceof ReblogOrReplyLineStatusDisplayItem)
.map(ReblogOrReplyLineStatusDisplayItem.class::cast)
.findFirst();
if (primaryLine.isPresent() && GlobalUserPreferences.compactReblogReplyLine) {
primaryLine.get().extra = replyLine;
} else {
items.add(replyLine);
boolean filtered=false;
if(status.filtered!=null){
for(FilterResult filter:status.filtered){
if(filter.filter.isActive()){
filtered=true;
break;
}
}
}
HeaderStatusDisplayItem header;
items.add(header=new HeaderStatusDisplayItem(parentID, statusForContent.account, statusForContent.createdAt, fragment, accountID, statusForContent, null, notification, scheduledStatus));
if (replyLine != null && !GlobalUserPreferences.replyLineAboveHeader) {
replyLine.belowHeader = true;
items.add(replyLine);
ArrayList<StatusDisplayItem> contentItems;
if(!TextUtils.isEmpty(statusForContent.spoilerText)){
if (AccountSessionManager.get(accountID).getLocalPreferences().revealCWs) statusForContent.spoilerRevealed = true;
SpoilerStatusDisplayItem spoilerItem=new SpoilerStatusDisplayItem(parentID, fragment, null, statusForContent, Type.SPOILER);
items.add(spoilerItem);
contentItems=spoilerItem.contentItems;
}else{
contentItems=items;
}
if (statusForContent.quote != null) {
@@ -195,74 +239,85 @@ public abstract class StatusDisplayItem{
statusForContent.content += quoteInline;
}
}
if(!TextUtils.isEmpty(statusForContent.content))
items.add(new TextStatusDisplayItem(parentID, HtmlParser.parse(statusForContent.content, statusForContent.emojis, statusForContent.mentions, statusForContent.tags, accountID), fragment, statusForContent, disableTranslate));
else if (!GlobalUserPreferences.replyLineAboveHeader && replyLine != null)
replyLine.needBottomPadding=true;
else
header.needBottomPadding=true;
List<Attachment> imageAttachments=statusForContent.mediaAttachments.stream()
.filter(att->att.type.isImage() && !att.type.equals(Attachment.Type.UNKNOWN))
.collect(Collectors.toList());
if(!TextUtils.isEmpty(statusForContent.content)){
SpannableStringBuilder parsedText=HtmlParser.parse(statusForContent.content, statusForContent.emojis, statusForContent.mentions, statusForContent.tags, accountID);
HtmlParser.applyFilterHighlights(fragment.getActivity(), parsedText, status.filtered);
TextStatusDisplayItem text=new TextStatusDisplayItem(parentID, HtmlParser.parse(statusForContent.content, statusForContent.emojis, statusForContent.mentions, statusForContent.tags, accountID), fragment, statusForContent, (flags & FLAG_NO_TRANSLATE) != 0);
contentItems.add(text);
} else if (header!=null){
header.needBottomPadding=true;
}
List<Attachment> imageAttachments=statusForContent.mediaAttachments.stream().filter(att->att.type.isImage()).collect(Collectors.toList());
if(!imageAttachments.isEmpty()){
int color = UiUtils.getThemeColor(fragment.getContext(), R.attr.colorAccentLightest);
for (Attachment att : imageAttachments) {
if (att.blurhashPlaceholder == null) {
att.blurhashPlaceholder = new ColorDrawable(color);
}
}
PhotoLayoutHelper.TiledLayoutResult layout=PhotoLayoutHelper.processThumbs(imageAttachments);
items.add(new MediaGridStatusDisplayItem(parentID, fragment, layout, imageAttachments, statusForContent));
MediaGridStatusDisplayItem mediaGrid=new MediaGridStatusDisplayItem(parentID, fragment, layout, imageAttachments, statusForContent);
if((flags & FLAG_MEDIA_FORCE_HIDDEN)!=0)
mediaGrid.sensitiveTitle=fragment.getString(R.string.media_hidden);
else if(statusForContent.sensitive && AccountSessionManager.get(accountID).getLocalPreferences().revealCWs && !AccountSessionManager.get(accountID).getLocalPreferences().hideSensitiveMedia)
statusForContent.sensitiveRevealed=true;
contentItems.add(mediaGrid);
}
for(Attachment att:statusForContent.mediaAttachments){
if(att.type==Attachment.Type.AUDIO){
items.add(new AudioStatusDisplayItem(parentID, fragment, statusForContent, att));
contentItems.add(new AudioStatusDisplayItem(parentID, fragment, statusForContent, att));
}
if(att.type==Attachment.Type.UNKNOWN){
contentItems.add(new FileStatusDisplayItem(parentID, fragment, att));
}
}
statusForContent.mediaAttachments.stream()
.filter(att->att.type.equals(Attachment.Type.UNKNOWN))
.map(att -> new FileStatusDisplayItem(parentID, fragment, att))
.forEach(items::add);
if(statusForContent.poll!=null){
buildPollItems(parentID, fragment, statusForContent.poll, items);
buildPollItems(parentID, fragment, statusForContent.poll, contentItems);
}
if(statusForContent.card!=null && statusForContent.mediaAttachments.isEmpty() && TextUtils.isEmpty(statusForContent.spoilerText)){
items.add(new LinkCardStatusDisplayItem(parentID, fragment, statusForContent));
if(statusForContent.card!=null && statusForContent.mediaAttachments.isEmpty()){
contentItems.add(new LinkCardStatusDisplayItem(parentID, fragment, statusForContent));
}
if(addFooter){
items.add(new FooterStatusDisplayItem(parentID, fragment, statusForContent, accountID));
if(contentItems!=items && statusForContent.spoilerRevealed){
items.addAll(contentItems);
}
if((flags & FLAG_NO_FOOTER)==0){
FooterStatusDisplayItem footer=new FooterStatusDisplayItem(parentID, fragment, statusForContent, accountID);
footer.hideCounts=hideCounts;
items.add(footer);
if(status.hasGapAfter && !(fragment instanceof ThreadFragment))
items.add(new GapStatusDisplayItem(parentID, fragment));
}
int i=1;
boolean inset=(flags & FLAG_INSET)!=0;
// add inset dummy so last content item doesn't clip out of inset bounds
if (inset) {
items.add(new InsetDummyStatusDisplayItem(parentID, fragment,
!contentItems.isEmpty() && contentItems
.get(contentItems.size() - 1) instanceof MediaGridStatusDisplayItem));
}
for(StatusDisplayItem item:items){
item.inset=inset;
item.index=i++;
}
if(items!=contentItems && !statusForContent.spoilerRevealed){
for(StatusDisplayItem item:contentItems){
item.inset=inset;
item.index=i++;
}
}
Filter applyingFilter = null;
LegacyFilter applyingFilter = null;
if (!statusForContent.filterRevealed) {
StatusFilterPredicate predicate = new StatusFilterPredicate(accountID, filterContext, Filter.FilterAction.WARN);
StatusFilterPredicate predicate = new StatusFilterPredicate(accountID, filterContext, FilterAction.WARN);
statusForContent.filterRevealed = predicate.test(status);
applyingFilter = predicate.getApplyingFilter();
}
ArrayList<StatusDisplayItem> result = statusForContent.filterRevealed ? items :
return statusForContent.filterRevealed ? items :
new ArrayList<>(List.of(new WarningFilteredStatusDisplayItem(parentID, fragment, statusForContent, items, applyingFilter)));
if (addFooter && status.hasGapAfter && !(fragment instanceof ThreadFragment)) {
StatusDisplayItem gap = new GapStatusDisplayItem(parentID, fragment);
gap.index = i++;
result.add(gap);
}
return result;
}
public static void buildPollItems(String parentID, BaseStatusListFragment fragment, Poll poll, List<StatusDisplayItem> items){
int i=0;
for(Poll.Option opt:poll.options){
items.add(new PollOptionStatusDisplayItem(parentID, poll, opt, fragment));
items.add(new PollOptionStatusDisplayItem(parentID, poll, i, fragment));
i++;
}
items.add(new PollFooterStatusDisplayItem(parentID, fragment, poll));
}
@@ -283,7 +338,13 @@ public abstract class StatusDisplayItem{
EXTENDED_FOOTER,
MEDIA_GRID,
WARNING,
FILE
FILE,
SPOILER,
SECTION_HEADER,
HEADER_CHECKABLE,
NOTIFICATION_HEADER,
FILTER_SPOILER,
DUMMY
}
public static abstract class Holder<T extends StatusDisplayItem> extends BindableViewHolder<T> implements UsableRecyclerView.DisableableClickable{

View File

@@ -6,8 +6,8 @@ import android.graphics.drawable.Drawable;
import android.text.TextUtils;
import android.view.View;
import android.view.ViewGroup;
import android.widget.LinearLayout;
import android.widget.Button;
import android.widget.LinearLayout;
import android.widget.ScrollView;
import android.widget.TextView;
import android.widget.Toast;
@@ -44,9 +44,9 @@ import me.grishka.appkit.utils.V;
public class TextStatusDisplayItem extends StatusDisplayItem{
private CharSequence text;
private CustomEmojiHelper emojiHelper=new CustomEmojiHelper(), spoilerEmojiHelper;
private CharSequence parsedSpoilerText;
private CustomEmojiHelper emojiHelper=new CustomEmojiHelper();
public boolean textSelectable;
public boolean reduceTopPadding;
public final Status status;
public boolean disableTranslate, translationShown;
private AccountSession session;
@@ -59,11 +59,6 @@ public class TextStatusDisplayItem extends StatusDisplayItem{
this.disableTranslate=disableTranslate;
this.translationShown=status.translationShown;
emojiHelper.setText(text);
if(!TextUtils.isEmpty(status.spoilerText)){
parsedSpoilerText=HtmlParser.parseCustomEmoji(status.spoilerText, status.emojis);
spoilerEmojiHelper=new CustomEmojiHelper();
spoilerEmojiHelper.setText(parsedSpoilerText);
}
session = AccountSessionManager.getInstance().getAccount(parentFragment.getAccountID());
}
@@ -79,24 +74,18 @@ public class TextStatusDisplayItem extends StatusDisplayItem{
@Override
public int getImageCount(){
if(spoilerEmojiHelper!=null && !status.spoilerRevealed)
return spoilerEmojiHelper.getImageCount();
return emojiHelper.getImageCount();
}
@Override
public ImageLoaderRequest getImageRequest(int index){
if(spoilerEmojiHelper!=null && !status.spoilerRevealed)
return spoilerEmojiHelper.getImageRequest(index);
return emojiHelper.getImageRequest(index);
}
public static class Holder extends StatusDisplayItem.Holder<TextStatusDisplayItem> implements ImageLoaderViewHolder{
private final LinkedTextView text;
private final LinearLayout spoilerHeader;
private final TextView spoilerTitle, spoilerTitleInline, translateInfo, readMore;
private final View spoilerOverlay, borderTop, borderBottom, textWrap, translateWrap, translateProgress, spaceBelowText;
private final int backgroundColor, borderColor;
private final TextView translateInfo, readMore;
private final View textWrap, translateWrap, translateProgress;
private final Button translateButton;
private final ScrollView textScrollView;
@@ -108,23 +97,13 @@ public class TextStatusDisplayItem extends StatusDisplayItem{
super(activity, R.layout.display_item_text, parent);
this.parent=parent;
text=findViewById(R.id.text);
spoilerTitle=findViewById(R.id.spoiler_title);
spoilerTitleInline=findViewById(R.id.spoiler_title_inline);
spoilerHeader=findViewById(R.id.spoiler_header);
spoilerOverlay=findViewById(R.id.spoiler_overlay);
borderTop=findViewById(R.id.border_top);
borderBottom=findViewById(R.id.border_bottom);
textWrap=findViewById(R.id.text_wrap);
textWrap = (LinearLayout) itemView;
translateWrap=findViewById(R.id.translate_wrap);
translateButton=findViewById(R.id.translate_btn);
translateInfo=findViewById(R.id.translate_info);
translateProgress=findViewById(R.id.translate_progress);
itemView.setOnClickListener(v->item.parentFragment.onRevealSpoilerClick(this));
backgroundColor=UiUtils.getThemeColor(activity, R.attr.colorBackgroundLight);
borderColor=UiUtils.getThemeColor(activity, R.attr.colorPollVoted);
textScrollView=findViewById(R.id.text_scroll_view);
readMore=findViewById(R.id.read_more);
spaceBelowText=findViewById(R.id.space_below_text);
textMaxHeight=activity.getResources().getDimension(R.dimen.text_max_height);
textCollapsedHeight=activity.getResources().getDimension(R.dimen.text_collapsed_height);
collapseParams=new LinearLayout.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, (int) textCollapsedHeight);
@@ -134,6 +113,7 @@ public class TextStatusDisplayItem extends StatusDisplayItem{
@Override
public void onBind(TextStatusDisplayItem item){
boolean hasSpoiler = !TextUtils.isEmpty(item.status.spoilerText);
text.setText(item.translationShown
? HtmlParser.parse(item.status.translation.content, item.status.emojis, item.status.mentions, item.status.tags, item.parentFragment.getAccountID())
: item.text);
@@ -141,32 +121,10 @@ public class TextStatusDisplayItem extends StatusDisplayItem{
if (item.textSelectable) {
textScrollView.setLayoutParams(new LinearLayout.LayoutParams(LinearLayout.LayoutParams.MATCH_PARENT, LinearLayout.LayoutParams.WRAP_CONTENT));
}
spoilerTitleInline.setTextIsSelectable(item.textSelectable);
text.setInvalidateOnEveryFrame(false);
spoilerTitleInline.setBackgroundColor(item.inset ? 0 : backgroundColor);
spoilerTitleInline.setPadding(spoilerTitleInline.getPaddingLeft(), item.inset ? 0 : V.dp(14), spoilerTitleInline.getPaddingRight(), item.inset ? 0 : V.dp(14));
borderTop.setBackgroundColor(item.inset ? 0 : borderColor);
borderBottom.setBackgroundColor(item.inset ? 0 : borderColor);
if(!TextUtils.isEmpty(item.status.spoilerText)){
spoilerTitle.setText(item.parsedSpoilerText);
spoilerTitleInline.setText(item.parsedSpoilerText);
if(item.status.spoilerRevealed){
spoilerOverlay.setVisibility(View.GONE);
spoilerHeader.setVisibility(View.VISIBLE);
textWrap.setVisibility(View.VISIBLE);
itemView.setClickable(false);
}else{
spoilerOverlay.setVisibility(View.VISIBLE);
spoilerHeader.setVisibility(View.GONE);
textWrap.setVisibility(View.GONE);
itemView.setClickable(true);
}
}else{
spoilerOverlay.setVisibility(View.GONE);
spoilerHeader.setVisibility(View.GONE);
textWrap.setVisibility(View.VISIBLE);
itemView.setClickable(false);
}
itemView.setClickable(false);
itemView.setPadding(itemView.getPaddingLeft(), item.reduceTopPadding ? V.dp(6) : V.dp(12), itemView.getPaddingRight(), itemView.getPaddingBottom());
text.setTextColor(UiUtils.getThemeColor(text.getContext(), item.inset ? R.attr.colorM3OnSurfaceVariant : R.attr.colorM3OnSurface));
Instance instanceInfo = AccountSessionManager.getInstance().getInstanceInfo(item.session.domain);
boolean translateEnabled = !item.disableTranslate && instanceInfo != null &&
@@ -234,7 +192,6 @@ public class TextStatusDisplayItem extends StatusDisplayItem{
});
readMore.setText(item.status.textExpanded ? R.string.sk_collapse : R.string.sk_expand);
spaceBelowText.setVisibility(translateVisible ? View.VISIBLE : View.GONE);
// remove additional padding when (transparently padded) translate button is visible
int nextPos = getAbsoluteAdapterPosition() + 1;
@@ -267,21 +224,25 @@ public class TextStatusDisplayItem extends StatusDisplayItem{
if (GlobalUserPreferences.collapseLongPosts && !item.status.textExpandable) {
boolean tooBig = text.getMeasuredHeight() > textMaxHeight;
boolean hasSpoiler = !TextUtils.isEmpty(item.status.spoilerText);
boolean expandable = tooBig && !hasSpoiler;
item.parentFragment.onEnableExpandable(Holder.this, expandable);
}
readMore.setVisibility(item.status.textExpandable && !item.status.textExpanded ? View.VISIBLE : View.GONE);
boolean expandButtonShown=item.status.textExpandable && !item.status.textExpanded;
translateWrap.setPadding(0, V.dp(expandButtonShown ? 0 : 4), 0, 0);
readMore.setVisibility(expandButtonShown ? View.VISIBLE : View.GONE);
textScrollView.setLayoutParams(item.status.textExpandable && !item.status.textExpanded ? collapseParams : wrapParams);
if (item.status.textExpandable && !translateVisible) spaceBelowText.setVisibility(View.VISIBLE);
// compensate for spoiler's bottom margin
ViewGroup.MarginLayoutParams params = (ViewGroup.MarginLayoutParams) itemView.getLayoutParams();
params.setMargins(params.leftMargin, (item.inset || GlobalUserPreferences.spectatorMode) && hasSpoiler ? V.dp(-16) : 0,
params.rightMargin, params.bottomMargin);
}
@Override
public void setImage(int index, Drawable image){
getEmojiHelper().setImageDrawable(index, image);
text.invalidate();
spoilerTitle.invalidate();
if(image instanceof Animatable){
((Animatable) image).start();
if(image instanceof MovieDrawable)
@@ -296,7 +257,7 @@ public class TextStatusDisplayItem extends StatusDisplayItem{
}
private CustomEmojiHelper getEmojiHelper(){
return item.spoilerEmojiHelper!=null && !item.status.spoilerRevealed ? item.spoilerEmojiHelper : item.emojiHelper;
return item.emojiHelper;
}
}
}

View File

@@ -7,7 +7,7 @@ import android.widget.TextView;
import org.joinmastodon.android.R;
import org.joinmastodon.android.fragments.BaseStatusListFragment;
import org.joinmastodon.android.model.Filter;
import org.joinmastodon.android.model.LegacyFilter;
import org.joinmastodon.android.model.Status;
import java.util.List;
@@ -16,9 +16,9 @@ public class WarningFilteredStatusDisplayItem extends StatusDisplayItem{
public boolean loading;
public final Status status;
public List<StatusDisplayItem> filteredItems;
public Filter applyingFilter;
public LegacyFilter applyingFilter;
public WarningFilteredStatusDisplayItem(String parentID, BaseStatusListFragment<?> parentFragment, Status status, List<StatusDisplayItem> filteredItems, Filter applyingFilter){
public WarningFilteredStatusDisplayItem(String parentID, BaseStatusListFragment<?> parentFragment, Status status, List<StatusDisplayItem> filteredItems, LegacyFilter applyingFilter){
super(parentID, parentFragment);
this.status=status;
this.filteredItems = filteredItems;

View File

@@ -0,0 +1,103 @@
package org.joinmastodon.android.ui.drawables;
import android.graphics.Canvas;
import android.graphics.ColorFilter;
import android.graphics.Paint;
import android.graphics.PixelFormat;
import android.graphics.Rect;
import android.graphics.drawable.Drawable;
import android.os.SystemClock;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import me.grishka.appkit.utils.CubicBezierInterpolator;
import me.grishka.appkit.utils.V;
public class AudioAttachmentBackgroundDrawable extends Drawable{
private int bgColor, wavesColor;
private Paint paint=new Paint(Paint.ANTI_ALIAS_FLAG);
private long[] animationStartTimes={0, 0};
private boolean animationRunning;
private Runnable[] restartRunnables={()->restartAnimation(0), ()->restartAnimation(1)};
@Override
public void draw(@NonNull Canvas canvas){
Rect bounds=getBounds();
paint.setColor(bgColor);
canvas.drawRect(bounds, paint);
float initialRadius=V.dp(48);
float finalRadius=bounds.width()/2f;
long time=SystemClock.uptimeMillis();
boolean animationsStillRunning=false;
for(int i=0;i<animationStartTimes.length;i++){
long t=time-animationStartTimes[i];
if(t<0)
continue;
float fraction=t/3000f;
if(fraction>1)
continue;
fraction=CubicBezierInterpolator.EASE_OUT.getInterpolation(fraction);
paint.setColor(wavesColor);
paint.setAlpha(Math.round(paint.getAlpha()*(1f-fraction)));
canvas.drawCircle(bounds.centerX(), bounds.centerY(), initialRadius+(finalRadius-initialRadius)*fraction, paint);
animationsStillRunning=true;
}
if(animationsStillRunning){
invalidateSelf();
}
}
@Override
public void setAlpha(int alpha){
}
@Override
public void setColorFilter(@Nullable ColorFilter colorFilter){
}
@Override
public int getOpacity(){
return PixelFormat.OPAQUE;
}
public void setColors(int bg, int waves){
bgColor=bg;
wavesColor=waves;
}
public void startAnimation(){
if(animationRunning)
return;
long time=SystemClock.uptimeMillis();
animationStartTimes[0]=time;
scheduleSelf(restartRunnables[0], time+3000);
scheduleSelf(restartRunnables[1], time+1500);
animationRunning=true;
invalidateSelf();
}
public void stopAnimation(boolean gracefully){
if(!animationRunning)
return;
animationRunning=false;
for(Runnable r:restartRunnables)
unscheduleSelf(r);
if(!gracefully){
animationStartTimes[0]=animationStartTimes[1]=0;
}
}
private void restartAnimation(int index){
long time=SystemClock.uptimeMillis();
animationStartTimes[index]=time;
if(animationRunning)
scheduleSelf(restartRunnables[index], time+3000);
}
}

View File

@@ -1,84 +0,0 @@
package org.joinmastodon.android.ui.drawables;
import android.graphics.Canvas;
import android.graphics.ColorFilter;
import android.graphics.Paint;
import android.graphics.Path;
import android.graphics.PixelFormat;
import android.graphics.Rect;
import android.graphics.drawable.Drawable;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import me.grishka.appkit.utils.V;
public class ComposeAutocompleteBackgroundDrawable extends Drawable{
private Path path=new Path();
private Paint paint=new Paint(Paint.ANTI_ALIAS_FLAG);
private int fillColor, arrowOffset;
public ComposeAutocompleteBackgroundDrawable(int fillColor){
this.fillColor=fillColor;
}
@Override
public void draw(@NonNull Canvas canvas){
Rect bounds=getBounds();
canvas.save();
canvas.translate(bounds.left, bounds.top);
paint.setColor(0x80000000);
canvas.drawPath(path, paint);
canvas.translate(0, V.dp(1));
paint.setColor(fillColor);
canvas.drawPath(path, paint);
int arrowSize=V.dp(10);
canvas.drawRect(0, arrowSize, bounds.width(), bounds.height(), paint);
canvas.restore();
}
@Override
public void setAlpha(int alpha){
}
@Override
public void setColorFilter(@Nullable ColorFilter colorFilter){
}
@Override
public int getOpacity(){
return PixelFormat.TRANSLUCENT;
}
public void setArrowOffset(int offset){
arrowOffset=offset;
updatePath();
invalidateSelf();
}
@Override
protected void onBoundsChange(Rect bounds){
super.onBoundsChange(bounds);
updatePath();
}
@Override
public boolean getPadding(@NonNull Rect padding){
padding.top=V.dp(11);
return true;
}
private void updatePath(){
path.rewind();
int arrowSize=V.dp(10);
path.moveTo(0, arrowSize*2);
path.lineTo(0, arrowSize);
path.lineTo(arrowOffset-arrowSize, arrowSize);
path.lineTo(arrowOffset, 0);
path.lineTo(arrowOffset+arrowSize, arrowSize);
path.lineTo(getBounds().width(), arrowSize);
path.lineTo(getBounds().width(), arrowSize*2);
path.close();
}
}

View File

@@ -1,58 +0,0 @@
package org.joinmastodon.android.ui.drawables;
import android.graphics.Canvas;
import android.graphics.ColorFilter;
import android.graphics.LinearGradient;
import android.graphics.Matrix;
import android.graphics.Paint;
import android.graphics.PixelFormat;
import android.graphics.Rect;
import android.graphics.Shader;
import android.graphics.drawable.Drawable;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import me.grishka.appkit.utils.V;
public class CoverOverlayGradientDrawable extends Drawable{
private LinearGradient gradient=new LinearGradient(0f, 0f, 0f, 100f, 0xB0000000, 0, Shader.TileMode.CLAMP);
private Matrix gradientMatrix=new Matrix();
private int topPadding, topOffset;
private Paint paint=new Paint();
public CoverOverlayGradientDrawable(){
paint.setShader(gradient);
}
@Override
public void draw(@NonNull Canvas canvas){
Rect bounds=getBounds();
gradientMatrix.setScale(1f, (bounds.height()-V.dp(40)-topPadding)/100f);
gradientMatrix.postTranslate(0, topPadding+topOffset);
gradient.setLocalMatrix(gradientMatrix);
canvas.drawRect(bounds, paint);
}
@Override
public void setAlpha(int alpha){
}
@Override
public void setColorFilter(@Nullable ColorFilter colorFilter){
}
@Override
public int getOpacity(){
return PixelFormat.TRANSLUCENT;
}
public void setTopPadding(int topPadding){
this.topPadding=topPadding;
}
public void setTopOffset(int topOffset){
this.topOffset=topOffset;
}
}

View File

@@ -0,0 +1,48 @@
package org.joinmastodon.android.ui.drawables;
import android.graphics.Canvas;
import android.graphics.ColorFilter;
import android.graphics.PixelFormat;
import android.graphics.drawable.Drawable;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
public class EmptyDrawable extends Drawable{
private final int width, height;
public EmptyDrawable(int width, int height){
this.width=width;
this.height=height;
}
@Override
public void draw(@NonNull Canvas canvas){
}
@Override
public void setAlpha(int alpha){
}
@Override
public void setColorFilter(@Nullable ColorFilter colorFilter){
}
@Override
public int getOpacity(){
return PixelFormat.TRANSPARENT;
}
@Override
public int getIntrinsicWidth(){
return width;
}
@Override
public int getIntrinsicHeight(){
return height;
}
}

View File

@@ -0,0 +1,74 @@
package org.joinmastodon.android.ui.drawables;
import android.content.Context;
import android.graphics.Canvas;
import android.graphics.ColorFilter;
import android.graphics.Matrix;
import android.graphics.Paint;
import android.graphics.Path;
import android.graphics.PixelFormat;
import android.graphics.Rect;
import android.graphics.drawable.Drawable;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import me.grishka.appkit.utils.V;
public class PlayIconDrawable extends Drawable{
private final Paint paint=new Paint(Paint.ANTI_ALIAS_FLAG);
private final Path path=new Path();
public PlayIconDrawable(Context context){
paint.setShadowLayer(V.dp(32), 0, 0, 0x80000000);
paint.setColor(0xffffffff);
path.moveTo(19.15f,32.5f);
path.lineTo(32.5f,24.0f);
path.lineTo(19.15f,15.5f);
path.moveTo(24.0f,44.0f);
path.quadTo(19.9f,44.0f,16.25f,42.42f);
path.quadTo(12.6f,40.85f,9.88f,38.13f);
path.quadTo(7.15f,35.4f,5.58f,31.75f);
path.quadTo(4.0f,28.1f,4.0f,24.0f);
path.quadTo(4.0f,19.85f,5.58f,16.2f);
path.quadTo(7.15f,12.55f,9.88f,9.85f);
path.quadTo(12.6f,7.15f,16.25f,5.58f);
path.quadTo(19.9f,4.0f,24.0f,4.0f);
path.quadTo(28.15f,4.0f,31.8f,5.58f);
path.quadTo(35.45f,7.15f,38.15f,9.85f);
path.quadTo(40.85f,12.55f,42.42f,16.2f);
path.quadTo(44.0f,19.85f,44.0f,24.0f);
path.quadTo(44.0f,28.1f,42.42f,31.75f);
path.quadTo(40.85f,35.4f,38.15f,38.13f);
path.quadTo(35.45f,40.85f,31.8f,42.42f);
path.quadTo(28.15f,44.0f,24.0f,44.0f);
Matrix matrix=new Matrix();
float density=context.getResources().getDisplayMetrics().density;
matrix.postScale(density*1.3333f, density*1.3333f);
path.transform(matrix);
}
@Override
public void draw(@NonNull Canvas c){
c.save();
Rect bounds=getBounds();
c.translate(bounds.width()/2f-V.dp(32), bounds.height()/2f-V.dp(32));
c.drawPath(path, paint);
c.restore();
}
@Override
public void setAlpha(int alpha){
}
@Override
public void setColorFilter(@Nullable ColorFilter colorFilter){
}
@Override
public int getOpacity(){
return PixelFormat.TRANSPARENT;
}
}

View File

@@ -61,9 +61,9 @@ public class SawtoothTearDrawable extends Drawable{
}
path.close();
Paint paint=new Paint(Paint.ANTI_ALIAS_FLAG);
paint.setColor(UiUtils.getThemeColor(context, R.attr.colorWindowBackground));
paint.setColor(UiUtils.getThemeColor(context, R.attr.colorM3Surface));
c.drawPath(path, paint);
paint.setColor(UiUtils.getThemeColor(context, R.attr.colorPollVoted));
paint.setColor(UiUtils.getThemeColor(context, R.attr.colorM3OutlineVariant));
paint.setStrokeWidth(actualStrokeWidth);
paint.setStyle(Paint.Style.STROKE);
c.drawPath(path, paint);

View File

@@ -1,76 +0,0 @@
package org.joinmastodon.android.ui.drawables;
import android.content.Context;
import android.graphics.Bitmap;
import android.graphics.Canvas;
import android.graphics.ColorFilter;
import android.graphics.Paint;
import android.graphics.PixelFormat;
import android.graphics.drawable.Drawable;
import org.joinmastodon.android.R;
import org.joinmastodon.android.ui.utils.UiUtils;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import me.grishka.appkit.utils.V;
public class SeekBarThumbDrawable extends Drawable{
private Bitmap shadow1, shadow2;
private Paint paint=new Paint(Paint.ANTI_ALIAS_FLAG);
private Context context;
public SeekBarThumbDrawable(Context context){
this.context=context;
shadow1=Bitmap.createBitmap(V.dp(24), V.dp(24), Bitmap.Config.ALPHA_8);
shadow2=Bitmap.createBitmap(V.dp(24), V.dp(24), Bitmap.Config.ALPHA_8);
Paint paint=new Paint(Paint.ANTI_ALIAS_FLAG);
paint.setColor(0xFF000000);
paint.setShadowLayer(V.dp(2), 0, V.dp(1), 0xFF000000);
new Canvas(shadow1).drawCircle(V.dp(12), V.dp(12), V.dp(9), paint);
paint.setShadowLayer(V.dp(3), 0, V.dp(1), 0xFF000000);
new Canvas(shadow2).drawCircle(V.dp(12), V.dp(12), V.dp(9), paint);
}
@Override
public void draw(@NonNull Canvas canvas){
float centerX=getBounds().centerX();
float centerY=getBounds().centerY();
paint.setStyle(Paint.Style.FILL);
paint.setColor(0x4d000000);
canvas.drawBitmap(shadow1, centerX-shadow1.getWidth()/2f, centerY-shadow1.getHeight()/2f, paint);
paint.setColor(0x26000000);
canvas.drawBitmap(shadow2, centerX-shadow2.getWidth()/2f, centerY-shadow2.getHeight()/2f, paint);
paint.setColor(UiUtils.getThemeColor(context, R.attr.colorButtonText));
canvas.drawCircle(centerX, centerY, V.dp(7), paint);
paint.setStyle(Paint.Style.STROKE);
paint.setColor(UiUtils.getThemeColor(context, R.attr.colorAccentLight));
paint.setStrokeWidth(V.dp(4));
canvas.drawCircle(centerX, centerY, V.dp(7), paint);
}
@Override
public void setAlpha(int alpha){
}
@Override
public void setColorFilter(@Nullable ColorFilter colorFilter){
}
@Override
public int getOpacity(){
return PixelFormat.TRANSLUCENT;
}
@Override
public int getIntrinsicWidth(){
return V.dp(24);
}
@Override
public int getIntrinsicHeight(){
return V.dp(24);
}
}

View File

@@ -11,14 +11,20 @@ import org.joinmastodon.android.MastodonApp;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import me.grishka.appkit.utils.V;
public class SpoilerStripesDrawable extends Drawable{
private Paint paint=new Paint(Paint.ANTI_ALIAS_FLAG);
private boolean flipped;
public SpoilerStripesDrawable(){
private static final float X1=-0.860365f;
private static final float X2=10.6078f;
public SpoilerStripesDrawable(boolean flipped){
paint.setColor(0xff000000);
paint.setStyle(Paint.Style.STROKE);
paint.setStrokeWidth(3);
this.flipped=flipped;
}
@Override
@@ -28,13 +34,15 @@ public class SpoilerStripesDrawable extends Drawable{
canvas.translate(bounds.left, bounds.top);
canvas.clipRect(0, 0, bounds.width(), bounds.height());
float scale=MastodonApp.context.getResources().getDisplayMetrics().density;
if(bounds.width()>V.dp(10))
scale*=2;
canvas.scale(scale, scale, 0, 0);
float height=bounds.height()/scale;
float y1=6.80133f;
float y2=-1.22874f;
while(y2<height){
canvas.drawLine(-0.860365f, y1, 10.6078f, y2, paint);
canvas.drawLine(flipped ? X2 : X1, y1, flipped ? X1 : X2, y2, paint);
y1+=8.03007f;
y2+=8.03007f;
}

View File

@@ -0,0 +1,48 @@
package org.joinmastodon.android.ui.drawables;
import android.graphics.Canvas;
import android.graphics.ColorFilter;
import android.graphics.Rect;
import android.graphics.drawable.Drawable;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
public class TiledDrawable extends Drawable{
private final Drawable drawable;
public TiledDrawable(Drawable drawable){
this.drawable=drawable;
}
@Override
public void draw(@NonNull Canvas canvas){
Rect bounds=getBounds();
canvas.save();
canvas.clipRect(bounds);
int w=drawable.getIntrinsicWidth();
int h=drawable.getIntrinsicHeight();
for(int y=bounds.top;y<bounds.bottom;y+=h){
for(int x=bounds.left;x<bounds.right;x+=w){
drawable.setBounds(x, y, x+w, y+h);
drawable.draw(canvas);
}
}
canvas.restore();
}
@Override
public void setAlpha(int alpha){
drawable.setAlpha(alpha);
}
@Override
public void setColorFilter(@Nullable ColorFilter colorFilter){
drawable.setColorFilter(colorFilter);
}
@Override
public int getOpacity(){
return drawable.getOpacity();
}
}

View File

@@ -19,7 +19,6 @@ import android.media.AudioManager;
import android.media.MediaPlayer;
import android.media.MediaScannerConnection;
import android.net.Uri;
import android.opengl.Visibility;
import android.os.Build;
import android.os.Environment;
import android.os.SystemClock;
@@ -200,7 +199,7 @@ public class PhotoViewer implements ZoomPanView.Listener{
videoSeekBar=uiOverlay.findViewById(R.id.seekbar);
videoTimeView=uiOverlay.findViewById(R.id.time);
videoPlayPauseButton=uiOverlay.findViewById(R.id.play_pause_btn);
if(attachments.get(index).type!=Attachment.Type.VIDEO){
if(attachments.get(index).type==Attachment.Type.IMAGE){
videoControls.setVisibility(View.GONE);
}else{
videoDuration=(int)Math.round(attachments.get(index).getDuration()*1000);
@@ -275,6 +274,10 @@ public class PhotoViewer implements ZoomPanView.Listener{
});
}
public void removeMenu(){
toolbar.getMenu().clear();
}
@Override
public void onTransitionAnimationUpdate(float translateX, float translateY, float scale){
listener.setTransitioningViewTransform(translateX, translateY, scale);
@@ -333,7 +336,7 @@ public class PhotoViewer implements ZoomPanView.Listener{
listener.setPhotoViewVisibility(pager.getCurrentItem(), true);
if(!uiVisible){
windowView.setSystemUiVisibility(windowView.getSystemUiVisibility() | View.SYSTEM_UI_FLAG_HIDE_NAVIGATION | View.SYSTEM_UI_FLAG_IMMERSIVE_STICKY | View.SYSTEM_UI_FLAG_FULLSCREEN);
}else if(attachments.get(currentIndex).type==Attachment.Type.VIDEO){
}else if(attachments.get(currentIndex).type!=Attachment.Type.IMAGE){
hideUiDelayed();
}
}
@@ -372,7 +375,7 @@ public class PhotoViewer implements ZoomPanView.Listener{
.setInterpolator(CubicBezierInterpolator.DEFAULT)
.start();
windowView.setSystemUiVisibility(windowView.getSystemUiVisibility() & ~(View.SYSTEM_UI_FLAG_HIDE_NAVIGATION | View.SYSTEM_UI_FLAG_IMMERSIVE_STICKY | View.SYSTEM_UI_FLAG_FULLSCREEN));
if(attachments.get(currentIndex).type==Attachment.Type.VIDEO)
if(attachments.get(currentIndex).type!=Attachment.Type.IMAGE)
hideUiDelayed(5000);
}
uiVisible=!uiVisible;
@@ -391,8 +394,7 @@ public class PhotoViewer implements ZoomPanView.Listener{
currentIndex=index;
Attachment att=attachments.get(index);
imageDescriptionButton.setVisible(att.description != null && !att.description.isEmpty());
toolbar.invalidate();
V.setVisibilityAnimated(videoControls, att.type==Attachment.Type.VIDEO ? View.VISIBLE : View.GONE);
V.setVisibilityAnimated(videoControls, att.type!=Attachment.Type.IMAGE ? View.VISIBLE : View.GONE);
if(att.type==Attachment.Type.VIDEO){
videoSeekBar.setSecondaryProgress(0);
videoDuration=(int)Math.round(att.getDuration()*1000);
@@ -821,12 +823,13 @@ public class PhotoViewer implements ZoomPanView.Listener{
if(item.type==Attachment.Type.VIDEO){
incKeepScreenOn();
keepingScreenOn=true;
}
if(getAbsoluteAdapterPosition()==currentIndex){
player.start();
startUpdatingVideoPosition(player);
hideUiDelayed();
}
}else{
if (item.type == Attachment.Type.GIFV) {
keepingScreenOn=false;
player.setLooping(true);
player.start();
@@ -846,7 +849,7 @@ public class PhotoViewer implements ZoomPanView.Listener{
player.setOnPreparedListener(this);
player.setOnErrorListener(this);
player.setOnVideoSizeChangedListener(this);
if(item.type==Attachment.Type.VIDEO){
if(item.type!=Attachment.Type.IMAGE){
player.setOnBufferingUpdateListener(this);
player.setOnInfoListener(this);
player.setOnSeekCompleteListener(this);

View File

@@ -79,6 +79,7 @@ import androidx.recyclerview.widget.util.Pools;
import androidx.viewpager.widget.PagerAdapter;
import androidx.viewpager.widget.ViewPager;
import me.grishka.appkit.utils.CubicBezierInterpolator;
import me.grishka.appkit.utils.CustomViewHelper;
import me.grishka.appkit.utils.V;
import java.lang.annotation.Retention;
@@ -157,7 +158,7 @@ import java.util.Iterator;
* @attr ref com.google.android.material.R.styleable#TabLayout_tabTextAppearance
*/
@ViewPager.DecorView
public class TabLayout extends HorizontalScrollView {
public class TabLayout extends HorizontalScrollView implements CustomViewHelper{
private static final CubicBezierInterpolator FAST_OUT_SLOW_IN_INTERPOLATOR=new CubicBezierInterpolator(.4f, 0f, .2f, 1f);
private static final int DEF_STYLE_RES = R.style.Widget_Design_TabLayout;
@@ -1657,7 +1658,7 @@ public class TabLayout extends HorizontalScrollView {
protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) {
// If we have a MeasureSpec which allows us to decide our height, try and use the default
// height
final int idealHeight = Math.round(V.dp(getDefaultHeight()));
final int idealHeight = Math.round(dp(getDefaultHeight()));
switch (MeasureSpec.getMode(heightMeasureSpec)) {
case MeasureSpec.AT_MOST:
if (getChildCount() == 1 && MeasureSpec.getSize(heightMeasureSpec) >= idealHeight) {
@@ -1680,7 +1681,7 @@ public class TabLayout extends HorizontalScrollView {
tabMaxWidth =
requestedTabMaxWidth > 0
? requestedTabMaxWidth
: (int) (specWidth - V.dp(TAB_MIN_WIDTH_MARGIN));
: (int) (specWidth - dp(TAB_MIN_WIDTH_MARGIN));
}
// Now super measure itself using the (possibly) modified height spec
@@ -2842,7 +2843,7 @@ public class TabLayout extends HorizontalScrollView {
int iconMargin = 0;
if (hasText && iconView.getVisibility() == VISIBLE) {
// If we're showing both text and icon, add some margin bottom to the icon
iconMargin = (int) V.dp(DEFAULT_GAP_TEXT_ICON);
iconMargin = (int) dp(DEFAULT_GAP_TEXT_ICON);
}
if (inlineLabel) {
if (iconMargin != lp.getMarginEnd()) {
@@ -3043,7 +3044,7 @@ public class TabLayout extends HorizontalScrollView {
return;
}
final int gutter = (int) V.dp(FIXED_WRAP_GUTTER_MIN);
final int gutter = (int) dp(FIXED_WRAP_GUTTER_MIN);
boolean remeasure = false;
if (largestTabWidth * count <= getMeasuredWidth() - gutter * 2) {

View File

@@ -31,7 +31,10 @@ public class CustomEmojiSpan extends ReplacementSpan{
public void draw(@NonNull Canvas canvas, CharSequence text, int start, int end, float x, int top, int y, int bottom, @NonNull Paint paint){
int size=Math.round(paint.descent()-paint.ascent());
if(drawable==null){
canvas.drawRect(x, top, x+size, top+size, paint);
int alpha=paint.getAlpha();
paint.setAlpha(alpha >> 1);
canvas.drawRoundRect(x, top, x+size, top+size, V.dp(2), V.dp(2), paint);
paint.setAlpha(alpha);
}else{
// AnimatedImageDrawable doesn't like when its bounds don't start at (0, 0)
Rect bounds=drawable.getBounds();

View File

@@ -1,10 +1,13 @@
package org.joinmastodon.android.ui.text;
import android.content.Context;
import android.graphics.Typeface;
import android.text.SpannableStringBuilder;
import android.text.Spanned;
import android.text.TextUtils;
import android.text.style.BackgroundColorSpan;
import android.text.style.BulletSpan;
import android.text.style.ForegroundColorSpan;
import android.text.style.LeadingMarginSpan;
import android.text.style.RelativeSizeSpan;
import android.text.style.StrikethroughSpan;
@@ -17,14 +20,18 @@ import android.widget.TextView;
import com.twitter.twittertext.Regex;
import org.joinmastodon.android.R;
import org.joinmastodon.android.model.Emoji;
import org.joinmastodon.android.model.FilterResult;
import org.joinmastodon.android.model.Hashtag;
import org.joinmastodon.android.model.Mention;
import org.joinmastodon.android.ui.utils.UiUtils;
import org.jsoup.Jsoup;
import org.jsoup.nodes.Document;
import org.jsoup.nodes.Element;
import org.jsoup.nodes.Node;
import org.jsoup.nodes.TextNode;
import org.jsoup.safety.Cleaner;
import org.jsoup.safety.Safelist;
import org.jsoup.select.NodeVisitor;
@@ -129,7 +136,7 @@ public class HtmlParser{
}else{
linkType=LinkSpan.Type.URL;
}
openSpans.add(new SpanInfo(new LinkSpan(href, null, linkType, accountID, text), ssb.length(), el));
openSpans.add(new SpanInfo(new LinkSpan(href, null, linkType, accountID), ssb.length(), el));
}
case "br" -> ssb.append('\n');
case "span" -> {
@@ -244,6 +251,13 @@ public class HtmlParser{
return Jsoup.clean(html, Safelist.none());
}
public static String stripAndRemoveInvisibleSpans(String html){
Document doc=Jsoup.parseBodyFragment(html);
doc.body().select("span.invisible").remove();
Cleaner cleaner=new Cleaner(Safelist.none());
return cleaner.clean(doc).body().html();
}
public static String text(String html) {
return Jsoup.parse(html).body().wholeText();
}
@@ -257,8 +271,27 @@ public class HtmlParser{
String url=matcher.group(3);
if(TextUtils.isEmpty(matcher.group(4)))
url="http://"+url;
ssb.setSpan(new LinkSpan(url, null, LinkSpan.Type.URL, null, url), matcher.start(3), matcher.end(3), 0);
ssb.setSpan(new LinkSpan(url, null, LinkSpan.Type.URL, null), matcher.start(3), matcher.end(3), 0);
}while(matcher.find()); // Find more URLs
return ssb;
}
public static void applyFilterHighlights(Context context, SpannableStringBuilder text, List<FilterResult> filters){
if (filters == null) return;
int fgColor=UiUtils.getThemeColor(context, R.attr.colorM3Error);
int bgColor=UiUtils.getThemeColor(context, R.attr.colorM3ErrorContainer);
for(FilterResult filter:filters){
if(!filter.filter.isActive())
continue;;
for(String word:filter.keywordMatches){
Matcher matcher=Pattern.compile("\\b"+Pattern.quote(word)+"\\b", Pattern.CASE_INSENSITIVE).matcher(text);
while(matcher.find()){
ForegroundColorSpan fg=new ForegroundColorSpan(fgColor);
BackgroundColorSpan bg=new BackgroundColorSpan(bgColor);
text.setSpan(bg, matcher.start(), matcher.end(), 0);
text.setSpan(fg, matcher.start(), matcher.end(), 0);
}
}
}
}
}

View File

@@ -0,0 +1,67 @@
package org.joinmastodon.android.ui.text;
import android.content.Context;
import android.text.Editable;
import android.text.TextWatcher;
import android.text.style.BackgroundColorSpan;
import android.text.style.ForegroundColorSpan;
import org.joinmastodon.android.R;
import org.joinmastodon.android.ui.utils.UiUtils;
public class LengthLimitHighlighter implements TextWatcher{
private final Context context;
private final int lengthLimit;
private BackgroundColorSpan overLimitBG;
private ForegroundColorSpan overLimitFG;
private boolean isOverLimit;
private OverLimitChangeListener listener;
public LengthLimitHighlighter(Context context, int lengthLimit){
this.context=context;
overLimitBG=new BackgroundColorSpan(UiUtils.getThemeColor(context, R.attr.colorM3ErrorContainer));
overLimitFG=new ForegroundColorSpan(UiUtils.getThemeColor(context, R.attr.colorM3Error));
this.lengthLimit=lengthLimit;
}
@Override
public void beforeTextChanged(CharSequence s, int start, int count, int after){
}
@Override
public void onTextChanged(CharSequence s, int start, int before, int count){
}
@Override
public void afterTextChanged(Editable s){
s.removeSpan(overLimitBG);
s.removeSpan(overLimitFG);
boolean newOverLimit=s.length()>lengthLimit;
if(newOverLimit){
int start=s.length()-(s.length()-lengthLimit);
int end=s.length();
s.setSpan(overLimitFG, start, end, 0);
s.setSpan(overLimitBG, start, end, 0);
}
if(newOverLimit!=isOverLimit){
isOverLimit=newOverLimit;
if(listener!=null)
listener.onOverLimitChanged(isOverLimit);
}
}
public LengthLimitHighlighter setListener(OverLimitChangeListener listener){
this.listener=listener;
return this;
}
public boolean isOverLimit(){
return isOverLimit;
}
public interface OverLimitChangeListener{
void onOverLimitChanged(boolean isOverLimit);
}
}

View File

@@ -35,6 +35,7 @@ public class LinkSpan extends CharacterStyle {
@Override
public void updateDrawState(TextPaint tp) {
tp.setColor(color=tp.linkColor);
tp.setUnderlineText(true);
}
public void onClick(Context context){

View File

@@ -1,2 +0,0 @@
package org.joinmastodon.android.ui.text;public class TagEditText {
}

View File

@@ -50,4 +50,10 @@ public class BlurHashDrawable extends Drawable{
public int getIntrinsicHeight(){
return height;
}
@NonNull
@Override
public Drawable mutate(){
return new BlurHashDrawable(bitmap, width, height);
}
}

View File

@@ -55,12 +55,18 @@ public class ColorPalette {
}
Resources.Theme t = context.getTheme();
t.applyStyle(R.style.ColorPalette_Fallback, true);
if (base != 0) t.applyStyle(base, true);
if (light != 0 && theme.equals(ThemePreference.LIGHT)) t.applyStyle(light, true);
else if (theme.equals(ThemePreference.DARK)) {
if (light != 0 && theme.equals(ThemePreference.LIGHT)) {
t.applyStyle(light, true);
} else if (theme.equals(ThemePreference.DARK)) {
t.applyStyle(R.style.ColorPalette_Dark, true);
if (trueBlackTheme) t.applyStyle(R.style.ColorPalette_Dark_TrueBlack, true);
if (dark != 0 && !trueBlackTheme) t.applyStyle(dark, true);
else if (black != 0 && trueBlackTheme) t.applyStyle(black, true);
} else if (theme.equals(ThemePreference.AUTO)) {
t.applyStyle(R.style.ColorPalette_AutoLightDark, true);
if (trueBlackTheme) t.applyStyle(R.style.ColorPalette_AutoLightDark_TrueBlack, true);
if (autoDark != 0 && !trueBlackTheme) t.applyStyle(autoDark, true);
else if (autoBlack != 0 && trueBlackTheme) t.applyStyle(autoBlack, true);
}

View File

@@ -4,67 +4,86 @@ import android.app.Activity;
import android.content.Context;
import android.content.SharedPreferences;
import android.view.View;
import android.view.ViewGroup;
import android.widget.FrameLayout;
import android.widget.ImageView;
import android.widget.TextView;
import org.joinmastodon.android.MastodonApp;
import org.joinmastodon.android.R;
import org.joinmastodon.android.api.session.AccountSessionManager;
import me.grishka.appkit.utils.CubicBezierInterpolator;
import java.util.EnumSet;
import androidx.recyclerview.widget.RecyclerView;
import me.grishka.appkit.utils.MergeRecyclerAdapter;
import me.grishka.appkit.utils.SingleViewRecyclerAdapter;
public class DiscoverInfoBannerHelper{
private View banner;
private final BannerType type;
private final String accountID;
private static EnumSet<BannerType> bannerTypesToShow=EnumSet.noneOf(BannerType.class);
public DiscoverInfoBannerHelper(BannerType type){
this.type=type;
}
private SharedPreferences getPrefs(){
return MastodonApp.context.getSharedPreferences("onboarding", Context.MODE_PRIVATE);
}
public void maybeAddBanner(FrameLayout view){
if(!getPrefs().getBoolean("bannerHidden_"+type, false)){
((Activity)view.getContext()).getLayoutInflater().inflate(R.layout.discover_info_banner, view);
banner=view.findViewById(R.id.discover_info_banner);
view.findViewById(R.id.banner_dismiss).setOnClickListener(this::onDismissClick);
TextView text=view.findViewById(R.id.banner_text);
text.setText(switch(type){
case TRENDING_POSTS -> R.string.trending_posts_info_banner;
case TRENDING_HASHTAGS -> R.string.trending_hashtags_info_banner;
case TRENDING_LINKS -> R.string.trending_links_info_banner;
case LOCAL_TIMELINE -> R.string.local_timeline_info_banner;
case FEDERATED_TIMELINE -> R.string.sk_federated_timeline_info_banner;
case POST_NOTIFICATIONS -> R.string.sk_notify_posts_info_banner;
case BUBBLE_TIMELINE -> R.string.sk_bubble_timeline_info_banner;
});
static{
for(BannerType t:BannerType.values()){
if(!getPrefs().getBoolean("bannerHidden_"+t, false))
bannerTypesToShow.add(t);
}
}
private void onDismissClick(View v){
if(banner==null)
return;
View _banner=banner;
banner.animate()
.alpha(0)
.setDuration(200)
.setInterpolator(CubicBezierInterpolator.DEFAULT)
.withEndAction(()->((ViewGroup)_banner.getParent()).removeView(_banner))
.start();
public DiscoverInfoBannerHelper(BannerType type, String accountID){
this.type=type;
this.accountID=accountID;
}
private static SharedPreferences getPrefs(){
return MastodonApp.context.getSharedPreferences("onboarding", Context.MODE_PRIVATE);
}
public void maybeAddBanner(RecyclerView list, MergeRecyclerAdapter adapter){
if(bannerTypesToShow.contains(type)){
banner=((Activity)list.getContext()).getLayoutInflater().inflate(R.layout.discover_info_banner, list, false);
TextView text=banner.findViewById(R.id.banner_text);
text.setText(switch(type){
case TRENDING_POSTS -> list.getResources().getString(R.string.trending_posts_info_banner);
case TRENDING_LINKS -> list.getResources().getString(R.string.trending_links_info_banner);
case FEDERATED_TIMELINE -> list.getResources().getString(R.string.sk_federated_timeline_info_banner);
case POST_NOTIFICATIONS -> list.getResources().getString(R.string.sk_notify_posts_info_banner);
case BUBBLE_TIMELINE -> list.getResources().getString(R.string.sk_bubble_timeline_info_banner);
case LOCAL_TIMELINE -> list.getResources().getString(R.string.local_timeline_info_banner, AccountSessionManager.get(accountID).domain);
case ACCOUNTS -> list.getResources().getString(R.string.recommended_accounts_info_banner);
});
ImageView icon=banner.findViewById(R.id.icon);
icon.setImageResource(switch(type){
case TRENDING_POSTS -> R.drawable.ic_fluent_arrow_trending_24_regular;
case TRENDING_LINKS -> R.drawable.ic_fluent_news_24_regular;
case ACCOUNTS -> R.drawable.ic_fluent_people_add_24_regular;
// no icon because those are displayed as timelines - with icon in top left
case LOCAL_TIMELINE, FEDERATED_TIMELINE, BUBBLE_TIMELINE, POST_NOTIFICATIONS -> 0;
});
adapter.addAdapter(new SingleViewRecyclerAdapter(banner));
}
}
public void onBannerBecameVisible(){
getPrefs().edit().putBoolean("bannerHidden_"+type, true).apply();
banner=null;
// bannerTypesToShow is not updated here on purpose so the banner keeps showing until the app is relaunched
}
public static void reset(){
SharedPreferences prefs=getPrefs();
SharedPreferences.Editor e=prefs.edit();
prefs.getAll().keySet().stream().filter(k->k.startsWith("bannerHidden_")).forEach(e::remove);
e.apply();
bannerTypesToShow=EnumSet.allOf(BannerType.class);
}
public enum BannerType{
TRENDING_POSTS,
TRENDING_HASHTAGS,
TRENDING_LINKS,
LOCAL_TIMELINE,
FEDERATED_TIMELINE,
POST_NOTIFICATIONS,
// ACCOUNTS,
ACCOUNTS,
BUBBLE_TIMELINE
}
}

View File

@@ -27,8 +27,8 @@ public class InsetStatusItemDecoration extends RecyclerView.ItemDecoration{
public InsetStatusItemDecoration(BaseStatusListFragment<?> listFragment){
this.listFragment=listFragment;
bgColor=UiUtils.getThemeColor(listFragment.getActivity(), android.R.attr.colorBackground);
borderColor=UiUtils.getThemeColor(listFragment.getActivity(), R.attr.colorPollVoted);
bgColor=UiUtils.getThemeColor(listFragment.getActivity(), R.attr.colorM3SurfaceVariant);
borderColor=UiUtils.getThemeColor(listFragment.getActivity(), R.attr.colorM3OutlineVariant);
}
@Override
@@ -94,10 +94,14 @@ public class InsetStatusItemDecoration extends RecyclerView.ItemDecoration{
outRect.left=pad;
if(insetRight)
outRect.right=pad;
if(!topSiblingInset)
outRect.top=pad;
if(!bottomSiblingInset)
outRect.bottom=pad;
// had to comment this out because animations with offsets aren't handled properly.
// can be worked around by manually applying top margins to items
// see InsetDummyStatusDisplayItem#onBinds
// if(!topSiblingInset)
// outRect.top=pad;
// if(!bottomSiblingInset)
// outRect.bottom=pad;
}
}
}

View File

@@ -2,10 +2,12 @@ package org.joinmastodon.android.ui.utils;
import android.content.Context;
import android.graphics.drawable.Drawable;
import android.os.Build;
import android.text.TextUtils;
import android.view.LayoutInflater;
import android.view.View;
import android.widget.ImageView;
import android.widget.TextView;
import org.joinmastodon.android.GlobalUserPreferences;
import org.joinmastodon.android.R;
@@ -13,12 +15,15 @@ import org.joinmastodon.android.model.Attachment;
import org.joinmastodon.android.model.Status;
import org.joinmastodon.android.ui.displayitems.MediaGridStatusDisplayItem;
import org.joinmastodon.android.ui.drawables.BlurhashCrossfadeDrawable;
import org.joinmastodon.android.ui.drawables.PlayIconDrawable;
public class MediaAttachmentViewController{
public final View view;
public final MediaGridStatusDisplayItem.GridItemType type;
public final ImageView photo;
public final View altButton, noAltButton, btnsWrap;
public final View altButton, noAltButton, btnsWrap, extraBadge;
public final TextView duration;
public final View playButton;
private BlurhashCrossfadeDrawable crossfadeDrawable=new BlurhashCrossfadeDrawable();
private final Context context;
private boolean didClear;
@@ -34,15 +39,24 @@ public class MediaAttachmentViewController{
altButton=view.findViewById(R.id.alt_button);
noAltButton=view.findViewById(R.id.no_alt_button);
btnsWrap=view.findViewById(R.id.alt_badges);
duration=view.findViewById(R.id.duration);
playButton=view.findViewById(R.id.play_button);
extraBadge=view.findViewById(R.id.extra_badge);
this.type=type;
this.context=context;
if(playButton!=null){
// https://developer.android.com/topic/performance/hardware-accel#drawing-support
if(Build.VERSION.SDK_INT<28)
playButton.setLayerType(View.LAYER_TYPE_SOFTWARE, null);
playButton.setBackground(new PlayIconDrawable(context));
}
}
public void bind(Attachment attachment, Status status){
this.status=status;
crossfadeDrawable.setSize(attachment.getWidth(), attachment.getHeight());
crossfadeDrawable.setBlurhashDrawable(attachment.blurhashPlaceholder);
crossfadeDrawable.setCrossfadeAlpha(status.spoilerRevealed ? 0f : 1f);
crossfadeDrawable.setCrossfadeAlpha(0f);
photo.setImageDrawable(null);
photo.setImageDrawable(crossfadeDrawable);
boolean hasAltText = !TextUtils.isEmpty(attachment.description);
@@ -52,12 +66,15 @@ public class MediaAttachmentViewController{
altButton.setVisibility(hasAltText && GlobalUserPreferences.showAltIndicator ? View.VISIBLE : View.GONE);
noAltButton.setVisibility(!hasAltText && GlobalUserPreferences.showNoAltIndicator ? View.VISIBLE : View.GONE);
}
if(type==MediaGridStatusDisplayItem.GridItemType.VIDEO){
duration.setText(UiUtils.formatMediaDuration((int)attachment.getDuration()));
}
didClear=false;
}
public void setImage(Drawable drawable){
crossfadeDrawable.setImageDrawable(drawable);
if(didClear && status.spoilerRevealed)
if(didClear)
crossfadeDrawable.animateAlpha(0f);
}
@@ -66,8 +83,4 @@ public class MediaAttachmentViewController{
crossfadeDrawable.setImageDrawable(null);
didClear=true;
}
public void setRevealed(boolean revealed){
crossfadeDrawable.animateAlpha(revealed ? 0f : 1f);
}
}

View File

@@ -37,6 +37,12 @@ import android.provider.OpenableColumns;
import android.text.SpannableStringBuilder;
import android.text.Spanned;
import android.text.TextUtils;
import android.text.style.TypefaceSpan;
import android.transition.ChangeBounds;
import android.transition.ChangeScroll;
import android.transition.Fade;
import android.transition.TransitionManager;
import android.transition.TransitionSet;
import android.util.Log;
import android.util.Pair;
import android.view.HapticFeedbackConstants;
@@ -44,6 +50,8 @@ import android.view.Menu;
import android.view.MenuItem;
import android.view.SubMenu;
import android.view.View;
import android.view.ViewGroup;
import android.view.WindowInsets;
import android.webkit.MimeTypeMap;
import android.widget.Button;
import android.widget.ImageView;
@@ -86,6 +94,7 @@ import org.joinmastodon.android.fragments.HashtagTimelineFragment;
import org.joinmastodon.android.fragments.ProfileFragment;
import org.joinmastodon.android.fragments.ThreadFragment;
import org.joinmastodon.android.model.Account;
import org.joinmastodon.android.model.AccountField;
import org.joinmastodon.android.model.Emoji;
import org.joinmastodon.android.model.Instance;
import org.joinmastodon.android.model.Notification;
@@ -97,6 +106,7 @@ import org.joinmastodon.android.model.Status;
import org.joinmastodon.android.model.StatusPrivacy;
import org.joinmastodon.android.ui.M3AlertDialogBuilder;
import org.joinmastodon.android.ui.text.CustomEmojiSpan;
import org.joinmastodon.android.ui.text.HtmlParser;
import org.parceler.Parcels;
import java.io.File;
@@ -106,20 +116,29 @@ import java.net.IDN;
import java.net.URI;
import java.net.URISyntaxException;
import java.time.Instant;
import java.time.LocalDate;
import java.time.ZoneId;
import java.time.ZonedDateTime;
import java.time.format.DateTimeFormatter;
import java.time.format.FormatStyle;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.Collections;
import java.util.Comparator;
import java.util.HashMap;
import java.util.List;
import java.util.Locale;
import java.util.Map;
import java.util.Objects;
import java.util.Optional;
import java.util.function.BiConsumer;
import java.util.function.BiPredicate;
import java.util.function.Consumer;
import java.util.function.Function;
import java.util.function.Predicate;
import java.util.function.ToIntFunction;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
import java.util.stream.Collectors;
import androidx.annotation.AttrRes;
@@ -137,6 +156,7 @@ 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;
import okhttp3.MediaType;
@@ -144,6 +164,7 @@ public class UiUtils {
private static Handler mainHandler = new Handler(Looper.getMainLooper());
private static final DateTimeFormatter DATE_FORMATTER_SHORT_WITH_YEAR = DateTimeFormatter.ofPattern("d MMM uuuu"), DATE_FORMATTER_SHORT = DateTimeFormatter.ofPattern("d MMM");
public static final DateTimeFormatter DATE_TIME_FORMATTER = DateTimeFormatter.ofLocalizedDateTime(FormatStyle.LONG, FormatStyle.SHORT);
private static final DateTimeFormatter TIME_FORMATTER=DateTimeFormatter.ofLocalizedTime(FormatStyle.SHORT);
public static int MAX_WIDTH, SCROLL_TO_TOP_DELTA;
private UiUtils() {
@@ -168,14 +189,14 @@ public class UiUtils {
long t = instant.toEpochMilli();
long now = System.currentTimeMillis();
long diff = now - t;
if (diff < 1000L) {
if(diff<1000L){
return context.getString(R.string.time_now);
} else if (diff < 60_000L) {
return context.getString(R.string.time_seconds, diff / 1000L);
} else if (diff < 3600_000L) {
return context.getString(R.string.time_minutes, diff / 60_000L);
} else if (diff < 3600_000L * 24L) {
return context.getString(R.string.time_hours, diff / 3600_000L);
}else if(diff<60_000L){
return context.getString(R.string.time_seconds_ago_short, diff/1000L);
}else if(diff<3600_000L){
return context.getString(R.string.time_minutes_ago_short, diff/60_000L);
}else if(diff<3600_000L*24L){
return context.getString(R.string.time_hours_ago_short, diff/3600_000L);
} else {
int days = (int) (diff / (3600_000L * 24L));
if (days > 30) {
@@ -186,25 +207,56 @@ public class UiUtils {
return DATE_FORMATTER_SHORT_WITH_YEAR.format(dt);
}
}
return context.getString(R.string.time_days, days);
return context.getString(R.string.time_days_ago_short, days);
}
}
public static String formatRelativeTimestampAsMinutesAgo(Context context, Instant instant) {
long t = instant.toEpochMilli();
long now = System.currentTimeMillis();
long diff = now - t;
if (diff < 1000L) {
public static String formatRelativeTimestampAsMinutesAgo(Context context, Instant instant, boolean relativeHours){
long t=instant.toEpochMilli();
long diff=System.currentTimeMillis()-t;
if(diff<1000L && diff>-1000L){
return context.getString(R.string.time_just_now);
} else if (diff < 60_000L) {
int secs = (int) (diff / 1000L);
return context.getResources().getQuantityString(R.plurals.x_seconds_ago, secs, secs);
} else if (diff < 3600_000L) {
int mins = (int) (diff / 60_000L);
return context.getResources().getQuantityString(R.plurals.x_minutes_ago, mins, mins);
} else {
return DATE_TIME_FORMATTER.format(instant.atZone(ZoneId.systemDefault()));
}else if(diff>0){
if(diff<60_000L){
int secs=(int)(diff/1000L);
return context.getResources().getQuantityString(R.plurals.x_seconds_ago, secs, secs);
}else if(diff<3600_000L){
int mins=(int)(diff/60_000L);
return context.getResources().getQuantityString(R.plurals.x_minutes_ago, mins, mins);
}else if(relativeHours && diff<24*3600_000L){
int hours=(int)(diff/3600_000L);
return context.getResources().getQuantityString(R.plurals.x_hours_ago, hours, hours);
}
}else{
if(diff>-60_000L){
int secs=-(int)(diff/1000L);
return context.getResources().getQuantityString(R.plurals.in_x_seconds, secs, secs);
}else if(diff>-3600_000L){
int mins=-(int)(diff/60_000L);
return context.getResources().getQuantityString(R.plurals.in_x_minutes, mins, mins);
}else if(relativeHours && diff>-24*3600_000L){
int hours=-(int)(diff/3600_000L);
return context.getResources().getQuantityString(R.plurals.in_x_hours, hours, hours);
}
}
ZonedDateTime dt=instant.atZone(ZoneId.systemDefault());
ZonedDateTime now=ZonedDateTime.now();
String formattedTime=TIME_FORMATTER.format(dt);
String formattedDate;
LocalDate today=now.toLocalDate();
LocalDate date=dt.toLocalDate();
if(date.equals(today)){
formattedDate=context.getString(R.string.today);
}else if(date.equals(today.minusDays(1))){
formattedDate=context.getString(R.string.yesterday);
}else if(date.equals(today.plusDays(1))){
formattedDate=context.getString(R.string.tomorrow);
}else if(date.getYear()==today.getYear()){
formattedDate=DATE_FORMATTER_SHORT.format(dt);
}else{
formattedDate=DATE_FORMATTER_SHORT_WITH_YEAR.format(dt);
}
return context.getString(R.string.date_at_time, formattedDate, formattedTime);
}
public static String formatTimeLeft(Context context, Instant instant) {
@@ -381,7 +433,7 @@ public class UiUtils {
}
public static void showConfirmationAlert(Context context, @StringRes int title, @StringRes int message, @StringRes int confirmButton, @DrawableRes int icon, Runnable onConfirmed) {
showConfirmationAlert(context, context.getString(title), context.getString(message), context.getString(confirmButton), icon, onConfirmed);
showConfirmationAlert(context, context.getString(title), message==0 ? null : context.getString(message), context.getString(confirmButton), icon, onConfirmed);
}
public static void showConfirmationAlert(Context context, CharSequence title, CharSequence message, CharSequence confirmButton, int icon, Runnable onConfirmed) {
@@ -626,49 +678,6 @@ public class UiUtils {
.exec(accountID));
}
public static void setRelationshipToActionButton(Relationship relationship, Button button) {
setRelationshipToActionButton(relationship, button, false);
}
public static void setRelationshipToActionButton(Relationship relationship, Button button, boolean keepText) {
CharSequence textBefore = keepText ? button.getText() : null;
boolean secondaryStyle;
if (relationship.blocking) {
button.setText(R.string.button_blocked);
secondaryStyle = true;
// } else if (relationship.blockedBy) {
// button.setText(R.string.button_follow);
// secondaryStyle = false;
} else if (relationship.requested) {
button.setText(R.string.button_follow_pending);
secondaryStyle = true;
} else if (!relationship.following) {
button.setText(relationship.followedBy ? R.string.follow_back : R.string.button_follow);
secondaryStyle = false;
} else {
button.setText(R.string.button_following);
secondaryStyle = true;
}
if (keepText) button.setText(textBefore);
// https://github.com/sk22/megalodon/issues/526
// button.setEnabled(!relationship.blockedBy);
int attr = secondaryStyle ? R.attr.secondaryButtonStyle : android.R.attr.buttonStyle;
TypedArray ta = button.getContext().obtainStyledAttributes(new int[]{attr});
int styleRes = ta.getResourceId(0, 0);
ta.recycle();
ta = button.getContext().obtainStyledAttributes(styleRes, new int[]{android.R.attr.background});
button.setBackground(ta.getDrawable(0));
ta.recycle();
ta = button.getContext().obtainStyledAttributes(styleRes, new int[]{android.R.attr.textColor});
if (relationship.blocking)
button.setTextColor(button.getResources().getColorStateList(R.color.error_600));
else
button.setTextColor(ta.getColorStateList(0));
ta.recycle();
}
public static void performToggleAccountNotifications(Activity activity, Account account, String accountID, Relationship relationship, Button button, Consumer<Boolean> progressCallback, Consumer<Relationship> resultCallback) {
progressCallback.accept(true);
new SetAccountFollowed(account.id, true, relationship.showingReblogs, !relationship.notifying)
@@ -689,26 +698,25 @@ public class UiUtils {
}
public static void setRelationshipToActionButtonM3(Relationship relationship, Button button){
boolean secondaryStyle;
int styleRes;
if(relationship.blocking){
button.setText(R.string.button_blocked);
secondaryStyle=true;
styleRes=R.style.Widget_Mastodon_M3_Button_Tonal_Error;
}else if(relationship.blockedBy){
button.setText(R.string.button_follow);
secondaryStyle=false;
styleRes=R.style.Widget_Mastodon_M3_Button_Filled;
}else if(relationship.requested){
button.setText(R.string.button_follow_pending);
secondaryStyle=true;
styleRes=R.style.Widget_Mastodon_M3_Button_Tonal;
}else if(!relationship.following){
button.setText(relationship.followedBy ? R.string.follow_back : R.string.button_follow);
secondaryStyle=false;
styleRes=R.style.Widget_Mastodon_M3_Button_Filled;
}else{
button.setText(R.string.button_following);
secondaryStyle=true;
styleRes=R.style.Widget_Mastodon_M3_Button_Tonal;
}
button.setEnabled(!relationship.blockedBy);
int styleRes=secondaryStyle ? R.style.Widget_Mastodon_M3_Button_Tonal : R.style.Widget_Mastodon_M3_Button_Filled;
TypedArray ta=button.getContext().obtainStyledAttributes(styleRes, new int[]{android.R.attr.background});
button.setBackground(ta.getDrawable(0));
ta.recycle();
@@ -891,8 +899,8 @@ public class UiUtils {
public static void setUserPreferredTheme(Context context) {
context.setTheme(switch (theme) {
case LIGHT -> R.style.Theme_Mastodon_Light;
case DARK -> trueBlackTheme ? R.style.Theme_Mastodon_Dark_TrueBlack : R.style.Theme_Mastodon_Dark;
default -> trueBlackTheme ? R.style.Theme_Mastodon_AutoLightDark_TrueBlack : R.style.Theme_Mastodon_AutoLightDark;
case DARK -> R.style.Theme_Mastodon_Dark;
default -> R.style.Theme_Mastodon_AutoLightDark;
});
ColorPalette palette = ColorPalette.palettes.get(GlobalUserPreferences.color);
@@ -903,6 +911,15 @@ public class UiUtils {
SCROLL_TO_TOP_DELTA = (int) res.getDimension(R.dimen.scroll_to_top_delta);
}
public static int alphaBlendThemeColors(Context context, @AttrRes int color1, @AttrRes int color2, float alpha){
if(UiUtils.isTrueBlackTheme()) return getThemeColor(context, color1);
return alphaBlendColors(getThemeColor(context, color1), getThemeColor(context, color2), alpha);
}
public static boolean isTrueBlackTheme(){
return isDarkTheme() && trueBlackTheme;
}
public static boolean isDarkTheme() {
if (theme == GlobalUserPreferences.ThemePreference.AUTO)
return (MastodonApp.context.getResources().getConfiguration().uiMode & Configuration.UI_MODE_NIGHT_MASK) == Configuration.UI_MODE_NIGHT_YES;
@@ -1011,18 +1028,29 @@ public class UiUtils {
return back;
}
public static boolean setExtraTextInfo(Context ctx, TextView extraText, StatusPrivacy visibility, boolean localOnly) {
public static boolean setExtraTextInfo(Context ctx, TextView extraText, TextView pronouns, boolean mentionedOnly, boolean localOnly, @Nullable Account account) {
List<String> extraParts = new ArrayList<>();
if (localOnly || (visibility != null && visibility.equals(StatusPrivacy.LOCAL)))
Optional<String> p=pronouns==null ? Optional.empty() : extractPronouns(ctx, account);
boolean setPronouns=false;
if(p.isPresent()) {
HtmlParser.setTextWithCustomEmoji(pronouns, p.get(), account.emojis);
setPronouns=true;
pronouns.setVisibility(View.VISIBLE);
}else if(pronouns!=null){
pronouns.setVisibility(View.GONE);
}
if(localOnly)
extraParts.add(ctx.getString(R.string.sk_inline_local_only));
if (visibility != null && visibility.equals(StatusPrivacy.DIRECT))
if(mentionedOnly)
extraParts.add(ctx.getString(R.string.sk_inline_direct));
if (!extraParts.isEmpty()) {
String sep = ctx.getString(R.string.sk_separator);
extraText.setText(String.join(" " + sep + " ", extraParts));
if(!extraParts.isEmpty()) {
String sepp = ctx.getString(R.string.sk_separator);
String text = String.join(" " + sepp + " ", extraParts);
if(account == null) extraText.setText(text);
else HtmlParser.setTextWithCustomEmoji(extraText, text, account.emojis);
extraText.setVisibility(View.VISIBLE);
return true;
} else {
}else{
extraText.setVisibility(View.GONE);
return false;
}
@@ -1443,4 +1471,182 @@ public class UiUtils {
}
};
}
@SuppressLint("DefaultLocale")
public static String formatMediaDuration(int seconds){
if(seconds>=3600)
return String.format("%d:%02d:%02d", seconds/3600, seconds%3600/60, seconds%60);
else
return String.format("%d:%02d", seconds/60, seconds%60);
}
public static void beginLayoutTransition(ViewGroup sceneRoot){
TransitionManager.beginDelayedTransition(sceneRoot, new TransitionSet()
.addTransition(new Fade(Fade.IN | Fade.OUT))
.addTransition(new ChangeBounds())
.addTransition(new ChangeScroll())
.setDuration(250)
.setInterpolator(CubicBezierInterpolator.DEFAULT)
);
}
public static Drawable getThemeDrawable(Context context, @AttrRes int attr){
TypedArray ta=context.obtainStyledAttributes(new int[]{attr});
Drawable d=ta.getDrawable(0);
ta.recycle();
return d;
}
public static WindowInsets applyBottomInsetToFixedView(View view, WindowInsets insets){
if(Build.VERSION.SDK_INT>=27){
int inset=insets.getSystemWindowInsetBottom();
view.setPadding(0, 0, 0, inset>0 ? Math.max(inset, V.dp(40)) : 0);
return insets.replaceSystemWindowInsets(insets.getSystemWindowInsetLeft(), insets.getSystemWindowInsetTop(), insets.getSystemWindowInsetRight(), 0);
}
return insets;
}
public static String formatDuration(Context context, int seconds){
if(seconds<3600){
int minutes=seconds/60;
return context.getResources().getQuantityString(R.plurals.x_minutes, minutes, minutes);
}else if(seconds<24*3600){
int hours=seconds/3600;
return context.getResources().getQuantityString(R.plurals.x_hours, hours, hours);
}else if(seconds>=7*24*3600 && seconds%(7*24*3600)<24*3600){
int weeks=seconds/(7*24*3600);
return context.getResources().getQuantityString(R.plurals.x_weeks, weeks, weeks);
}else{
int days=seconds/(24*3600);
return context.getResources().getQuantityString(R.plurals.x_days, days, days);
}
}
public static void openSystemShareSheet(Context context, String url){
Intent intent=new Intent(Intent.ACTION_SEND);
intent.setType("text/plain");
intent.putExtra(Intent.EXTRA_TEXT, url);
context.startActivity(Intent.createChooser(intent, context.getString(R.string.share_toot_title)));
}
private static final Pattern formatStringSubstitutionPattern = Pattern.compile("%(?:(\\d)\\$)?s");
public static CharSequence generateFormattedString(String format, CharSequence... args) {
if (format.startsWith(" ")) format = format.substring(1);
if (format.endsWith(" ")) format = format.substring(0, format.length() - 1);
Map<Integer, Integer> formatIndices = new HashMap<>();
String[] partsInBetween = formatStringSubstitutionPattern.split(format, -1);
SpannableStringBuilder text = new SpannableStringBuilder();
Matcher m = formatStringSubstitutionPattern.matcher(format);
int argsMaxIndex = 0;
while (m.find()) {
String group = m.groupCount() < 1 ? null : m.group(1);
int index = formatIndices.size();
try { index = Integer.parseInt(group); }
catch (Exception ignored) {}
formatIndices.put(index, argsMaxIndex++);
}
int formatOffset = formatIndices.size() > 0 ? Collections.min(formatIndices.keySet()) : 0;
int argsOffset = 0;
// say, string is just 'reacted with %s', but there are two arguments
if (args.length > argsMaxIndex) {
text.append(args[0], new TypefaceSpan("sans-serif-medium"), 0).append(' ');
argsOffset++;
}
// join the args with the parts in between
for (int i = 0; i < partsInBetween.length; i++) {
text.append(partsInBetween[i]);
Integer pos = formatIndices.get(i + formatOffset);
if (pos != null && pos < args.length) {
text.append(args[pos + argsOffset], new TypefaceSpan("sans-serif-medium"), 0);
}
}
// add additional args to the end of the string
if (args.length > argsMaxIndex + 1) {
for (int i = argsMaxIndex + 1; i < args.length; i++) {
text.append(' ').append(args[i], new TypefaceSpan("sans-serif-medium"), 0);
}
}
return text;
}
private static final String[] pronounsUrls= new String[] {
"pronouns.within.lgbt/",
"pronouns.cc/pronouns/",
"pronouns.page/"
};
private static final Pattern trimPronouns=Pattern.compile("[^\\w*]*([\\w*].*[\\w*]|[\\w*])\\W*");
private static String extractPronounsFromField(String localizedPronouns, AccountField field) {
if(!field.name.toLowerCase().contains(localizedPronouns) &&
!field.name.toLowerCase().contains("pronouns")) return null;
String text=HtmlParser.strip(field.value);
if(field.value.toLowerCase().contains("https://")){
for(String pronounUrl : pronounsUrls){
int index=text.indexOf(pronounUrl);
int beginPronouns=index+pronounUrl.length();
// we only want to display the info from the urls if they're not usernames
if(index>-1 && beginPronouns<text.length() && text.charAt(beginPronouns)!='@'){
return text.substring(beginPronouns);
}
}
// maybe it's like "they and them (https://pronouns.page/...)"
String[] parts=text.substring(0, text.toLowerCase().indexOf("https://"))
.split(" ");
if (parts.length==0) return null;
text=String.join(" ", parts);
}
Matcher matcher=trimPronouns.matcher(text);
if(!matcher.find()) return null;
String matched=matcher.group(1);
// crude fix to allow for pronouns like "it(/she)"
int missingClosingParens=0;
for(char c : matched.toCharArray()){
if(c=='(') missingClosingParens++;
if(c==')') missingClosingParens--;
}
return matched+")".repeat(Math.max(0, missingClosingParens));
}
// https://stackoverflow.com/questions/9475589/how-to-get-string-from-different-locales-in-android
public static Context getLocalizedContext(Context context, Locale desiredLocale) {
Configuration conf = context.getResources().getConfiguration();
conf = new Configuration(conf);
conf.setLocale(desiredLocale);
return context.createConfigurationContext(conf);
}
public static Optional<String> extractPronouns(Context context, @Nullable Account account) {
if (account == null) return Optional.empty();
String localizedPronouns=context.getString(R.string.sk_pronouns_label).toLowerCase();
// higher = worse. the lowest number wins. also i'm sorry for writing this
ToIntFunction<AccountField> comparePronounFields=(f)->{
String t=f.name.toLowerCase();
int localizedIndex = t.indexOf(localizedPronouns);
int englishIndex = t.indexOf("pronouns");
// neutralizing an english fallback failure if the localized pronoun already succeeded
// -t.length() + t.length() = 0 -> so the low localized score doesn't get obscured
if (englishIndex < 0) englishIndex = localizedIndex > -1 ? -t.length() : t.length();
if (localizedIndex < 0) localizedIndex = t.length();
return (localizedIndex + t.length()) + (englishIndex + t.length()) * 100;
};
// debugging:
// List<Integer> ints = account.fields.stream().map(comparePronounFields::applyAsInt).collect(Collectors.toList());
// List<AccountField> sorted = account.fields.stream().sorted(Comparator.comparingInt(comparePronounFields)).collect(Collectors.toList());
return account.fields.stream()
.sorted(Comparator.comparingInt(comparePronounFields))
.map(f->UiUtils.extractPronounsFromField(localizedPronouns, f))
.filter(Objects::nonNull)
.findFirst();
}
}

View File

@@ -1,15 +1,15 @@
package org.joinmastodon.android.ui;
package org.joinmastodon.android.ui.viewcontrollers;
import android.annotation.SuppressLint;
import android.app.Activity;
import android.graphics.Typeface;
import android.graphics.Rect;
import android.graphics.drawable.ColorDrawable;
import android.graphics.drawable.Drawable;
import android.text.TextUtils;
import android.view.Gravity;
import android.view.View;
import android.view.ViewGroup;
import android.widget.FrameLayout;
import android.widget.ImageView;
import android.widget.ProgressBar;
import android.widget.LinearLayout;
import android.widget.TextView;
import org.joinmastodon.android.R;
@@ -19,14 +19,18 @@ 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.drawables.ComposeAutocompleteBackgroundDrawable;
import org.joinmastodon.android.model.viewmodel.AccountViewModel;
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.HideableSingleViewRecyclerAdapter;
import org.joinmastodon.android.ui.utils.UiUtils;
import org.joinmastodon.android.ui.views.FilterChipView;
import java.util.ArrayList;
import java.util.Collections;
import java.util.List;
import java.util.function.Consumer;
import java.util.stream.Collectors;
import java.util.stream.Stream;
@@ -43,54 +47,66 @@ import me.grishka.appkit.imageloader.RecyclerViewDelegate;
import me.grishka.appkit.imageloader.requests.ImageLoaderRequest;
import me.grishka.appkit.imageloader.requests.UrlImageLoaderRequest;
import me.grishka.appkit.utils.BindableViewHolder;
import me.grishka.appkit.utils.MergeRecyclerAdapter;
import me.grishka.appkit.utils.V;
import me.grishka.appkit.views.UsableRecyclerView;
public class ComposeAutocompleteViewController{
private static final int LOADING_FAKE_USER_COUNT=3;
private Activity activity;
private String accountID;
private FrameLayout contentView;
private UsableRecyclerView list;
private ListImageLoaderWrapper imgLoader;
private ProgressBar progress;
private List<WrappedAccount> users=Collections.emptyList();
private List<AccountViewModel> users=Collections.emptyList();
private List<Hashtag> hashtags=Collections.emptyList();
private List<WrappedEmoji> emojis=Collections.emptyList();
private Mode mode;
private APIRequest currentRequest;
private Runnable usersDebouncer=this::doSearchUsers, hashtagsDebouncer=this::doSearchHashtags;
private String lastText;
private ComposeAutocompleteBackgroundDrawable background;
private boolean listIsHidden=true;
private boolean isLoading;
private FilterChipView emptyButton;
private HideableSingleViewRecyclerAdapter emptyButtonAdapter;
private UsersAdapter usersAdapter;
private HashtagsAdapter hashtagsAdapter;
private EmojisAdapter emojisAdapter;
private MergeRecyclerAdapter usersMergeAdapter;
private MergeRecyclerAdapter emojisMergeAdapter;
private Consumer<String> completionSelectedListener;
private DividerItemDecoration usersDividers, hashtagsDividers;
private AutocompleteListener completionSelectedListener;
public ComposeAutocompleteViewController(Activity activity, String accountID){
this.activity=activity;
this.accountID=accountID;
background=new ComposeAutocompleteBackgroundDrawable(UiUtils.getThemeColor(activity, android.R.attr.colorBackground));
contentView=new FrameLayout(activity);
contentView.setBackground(background);
list=new UsableRecyclerView(activity);
list.setLayoutManager(new LinearLayoutManager(activity));
list.setLayoutManager(new LinearLayoutManager(activity, LinearLayoutManager.HORIZONTAL, false));
list.setItemAnimator(new BetterItemAnimator());
list.setVisibility(View.GONE);
list.setPadding(V.dp(16), V.dp(4), V.dp(16), V.dp(4));
list.setClipToPadding(false);
list.setSelector(null);
list.addItemDecoration(new RecyclerView.ItemDecoration(){
@Override
public void getItemOffsets(@NonNull Rect outRect, @NonNull View view, @NonNull RecyclerView parent, @NonNull RecyclerView.State state){
if(parent.getChildAdapterPosition(view)<parent.getAdapter().getItemCount()-1)
outRect.right=V.dp(8);
}
});
contentView.addView(list, new FrameLayout.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.MATCH_PARENT));
progress=new ProgressBar(activity);
FrameLayout.LayoutParams progressLP=new FrameLayout.LayoutParams(V.dp(48), V.dp(48), Gravity.CENTER_HORIZONTAL|Gravity.TOP);
progressLP.topMargin=V.dp(16);
contentView.addView(progress, progressLP);
usersDividers=new DividerItemDecoration(activity, R.attr.colorPollVoted, 1, 72, 16);
hashtagsDividers=new DividerItemDecoration(activity, R.attr.colorPollVoted, 1, 16, 16);
emptyButton=new FilterChipView(activity);
emptyButtonAdapter=new HideableSingleViewRecyclerAdapter(emptyButton);
emptyButton.setOnClickListener(v->{
if(mode==Mode.EMOJIS){
completionSelectedListener.onSetEmojiPanelOpen(true);
}else if(mode==Mode.USERS){
completionSelectedListener.onLaunchAccountSearch();
}
});
imgLoader=new ListImageLoaderWrapper(activity, list, new RecyclerViewDelegate(list), null);
}
@@ -101,13 +117,15 @@ public class ComposeAutocompleteViewController{
}else if(mode==Mode.HASHTAGS){
list.removeCallbacks(hashtagsDebouncer);
}
if(text==null)
return;
Mode prevMode=mode;
if(currentRequest!=null){
currentRequest.cancel();
currentRequest=null;
}
if(text==null){
reset();
return;
}
Mode prevMode=mode;
mode=switch(text.charAt(0)){
case '@' -> Mode.USERS;
case '#' -> Mode.HASHTAGS;
@@ -115,16 +133,33 @@ public class ComposeAutocompleteViewController{
default -> throw new IllegalStateException("Unexpected value: "+text.charAt(0));
};
if(prevMode!=mode){
if(mode==Mode.USERS){
isLoading=true;
emptyButtonAdapter.setVisible(false);
}
list.setAdapter(switch(mode){
case USERS -> {
if(usersAdapter==null)
if(usersAdapter==null){
usersAdapter=new UsersAdapter();
yield usersAdapter;
usersMergeAdapter=new MergeRecyclerAdapter();
usersMergeAdapter.addAdapter(emptyButtonAdapter);
usersMergeAdapter.addAdapter(usersAdapter);
}
emptyButton.setText(R.string.compose_autocomplete_users_empty);
emptyButton.setDrawableStartTinted(R.drawable.ic_fluent_search_20_regular);
yield usersMergeAdapter;
}
case EMOJIS -> {
if(emojisAdapter==null)
if(emojisAdapter==null){
emojisAdapter=new EmojisAdapter();
yield emojisAdapter;
emojisMergeAdapter=new MergeRecyclerAdapter();
emojisMergeAdapter.addAdapter(emptyButtonAdapter);
emojisMergeAdapter.addAdapter(emojisAdapter);
}
emptyButton.setText(R.string.compose_autocomplete_emoji_empty);
emptyButton.setDrawableStartTinted(R.drawable.ic_fluent_emoji_20_regular);
yield emojisMergeAdapter;
}
case HASHTAGS -> {
if(hashtagsAdapter==null)
@@ -132,25 +167,18 @@ public class ComposeAutocompleteViewController{
yield hashtagsAdapter;
}
});
if(mode!=Mode.EMOJIS){
list.setVisibility(View.GONE);
progress.setVisibility(View.VISIBLE);
listIsHidden=true;
}else if(listIsHidden){
list.setVisibility(View.VISIBLE);
progress.setVisibility(View.GONE);
listIsHidden=false;
}
if((prevMode==Mode.HASHTAGS)!=(mode==Mode.HASHTAGS) || prevMode==null){
if(prevMode!=null)
list.removeItemDecoration(prevMode==Mode.HASHTAGS ? hashtagsDividers : usersDividers);
list.addItemDecoration(mode==Mode.HASHTAGS ? hashtagsDividers : usersDividers);
}
}
lastText=text;
if(mode==Mode.USERS){
list.postDelayed(usersDebouncer, 300);
}else if(mode==Mode.HASHTAGS){
List<Hashtag> oldList=hashtags;
hashtags=new ArrayList<>();
Hashtag tag=new Hashtag();
tag.name=lastText.substring(1);
hashtags.add(tag);
UiUtils.updateList(oldList, hashtags, list, hashtagsAdapter, (t1, t2)->t1.name.equals(t2.name));
list.postDelayed(hashtagsDebouncer, 300);
}else if(mode==Mode.EMOJIS){
String _text=text.substring(1); // remove ':'
@@ -167,38 +195,56 @@ public class ComposeAutocompleteViewController{
.filter(e -> e.shortcode.toLowerCase().contains(_text.toLowerCase())))
.map(WrappedEmoji::new)
.collect(Collectors.toList());
emptyButtonAdapter.setVisible(emojis.isEmpty());
UiUtils.updateList(oldList, emojis, list, emojisAdapter, (e1, e2)->e1.emoji.shortcode.equals(e2.emoji.shortcode));
list.invalidateItemDecorations();
imgLoader.updateImages();
}
}
public void setCompletionSelectedListener(Consumer<String> completionSelectedListener){
public void setCompletionSelectedListener(AutocompleteListener completionSelectedListener){
this.completionSelectedListener=completionSelectedListener;
}
public void setArrowOffset(int offset){
background.setArrowOffset(offset);
}
public View getView(){
return contentView;
}
public void reset(){
mode=null;
users.clear();
emojis.clear();
hashtags.clear();
}
public Mode getMode(){
return mode;
}
private void doSearchUsers(){
currentRequest=new GetSearchResults(lastText, GetSearchResults.Type.ACCOUNTS, false)
.setCallback(new Callback<>(){
@Override
public void onSuccess(SearchResults result){
currentRequest=null;
List<WrappedAccount> oldList=users;
users=result.accounts.stream().map(WrappedAccount::new).collect(Collectors.toList());
UiUtils.updateList(oldList, users, list, usersAdapter, (a1, a2)->a1.account.id.equals(a2.account.id));
imgLoader.updateImages();
if(listIsHidden){
listIsHidden=false;
V.setVisibilityAnimated(list, View.VISIBLE);
V.setVisibilityAnimated(progress, View.GONE);
List<AccountViewModel> oldList=users;
users=result.accounts.stream().map(a->new AccountViewModel(a, accountID)).collect(Collectors.toList());
if(isLoading){
isLoading=false;
if(users.size()>=LOADING_FAKE_USER_COUNT){
usersAdapter.notifyItemRangeChanged(0, LOADING_FAKE_USER_COUNT);
if(users.size()>LOADING_FAKE_USER_COUNT)
usersAdapter.notifyItemRangeInserted(LOADING_FAKE_USER_COUNT, users.size()-LOADING_FAKE_USER_COUNT);
}else{
usersAdapter.notifyItemRangeChanged(0, users.size());
usersAdapter.notifyItemRangeRemoved(users.size(), LOADING_FAKE_USER_COUNT-users.size());
}
}else{
UiUtils.updateList(oldList, users, list, usersAdapter, (a1, a2)->a1.account.id.equals(a2.account.id));
}
list.invalidateItemDecorations();
emptyButtonAdapter.setVisible(users.isEmpty());
imgLoader.updateImages();
}
@Override
@@ -215,15 +261,12 @@ public class ComposeAutocompleteViewController{
@Override
public void onSuccess(SearchResults result){
currentRequest=null;
if(result.hashtags.isEmpty() || (result.hashtags.size()==1 && result.hashtags.get(0).name.equals(lastText.substring(1))))
return;
List<Hashtag> oldList=hashtags;
hashtags=result.hashtags;
UiUtils.updateList(oldList, hashtags, list, hashtagsAdapter, (t1, t2)->t1.name.equals(t2.name));
imgLoader.updateImages();
if(listIsHidden){
listIsHidden=false;
V.setVisibilityAnimated(list, View.VISIBLE);
V.setVisibilityAnimated(progress, View.GONE);
}
list.invalidateItemDecorations();
}
@Override
@@ -242,56 +285,67 @@ public class ComposeAutocompleteViewController{
@NonNull
@Override
public UserViewHolder onCreateViewHolder(@NonNull ViewGroup parent, int viewType){
return new UserViewHolder();
return switch(viewType){
case 0 -> new UserViewHolder();
case 1 -> new LoadingUserViewHolder();
default -> throw new IllegalStateException("Unexpected value: "+viewType);
};
}
@Override
public int getItemCount(){
if(isLoading)
return LOADING_FAKE_USER_COUNT;
return users.size();
}
@Override
public void onBindViewHolder(UserViewHolder holder, int position){
holder.bind(users.get(position));
super.onBindViewHolder(holder, position);
if(!isLoading){
holder.bind(users.get(position));
super.onBindViewHolder(holder, position);
}
}
@Override
public int getImageCountForItem(int position){
return 1+users.get(position).emojiHelper.getImageCount();
return isLoading ? 0 : 1;
}
@Override
public ImageLoaderRequest getImageRequest(int position, int image){
WrappedAccount a=users.get(position);
AccountViewModel a=users.get(position);
if(image==0)
return a.avaRequest;
return a.emojiHelper.getImageRequest(image-1);
}
@Override
public int getItemViewType(int position){
return isLoading ? 1 : 0;
}
}
private class UserViewHolder extends BindableViewHolder<WrappedAccount> implements ImageLoaderViewHolder, UsableRecyclerView.Clickable{
private final ImageView ava;
private final TextView name, username;
private class UserViewHolder extends BindableViewHolder<AccountViewModel> implements ImageLoaderViewHolder, UsableRecyclerView.Clickable{
protected final ImageView ava;
protected final TextView username;
private UserViewHolder(){
public UserViewHolder(){
super(activity, R.layout.item_autocomplete_user, list);
ava=findViewById(R.id.photo);
name=findViewById(R.id.name);
username=findViewById(R.id.username);
ava.setOutlineProvider(OutlineProviders.roundedRect(12));
ava.setOutlineProvider(OutlineProviders.OVAL);
ava.setClipToOutline(true);
}
@Override
public void onBind(WrappedAccount item){
name.setText(item.parsedName);
public void onBind(AccountViewModel item){
username.setText("@"+item.account.acct);
}
@Override
public void onClick(){
completionSelectedListener.accept("@"+item.account.acct);
completionSelectedListener.onCompletionSelected("@"+item.account.acct);
}
@Override
@@ -300,13 +354,29 @@ public class ComposeAutocompleteViewController{
ava.setImageDrawable(image);
}else{
item.emojiHelper.setImageDrawable(index-1, image);
name.invalidate();
}
}
@Override
public void clearImage(int index){
setImage(index, null);
if(index==0)
ava.setImageResource(R.drawable.image_placeholder);
else
setImage(index, null);
}
}
private class LoadingUserViewHolder extends UserViewHolder implements UsableRecyclerView.DisableableClickable{
public LoadingUserViewHolder(){
int color=UiUtils.getThemeColor(activity, R.attr.colorM3OutlineVariant);
ava.setImageDrawable(new ColorDrawable(color));
username.setLayoutParams(new LinearLayout.LayoutParams(V.dp(64), V.dp(10)));
username.setBackgroundColor(color);
}
@Override
public boolean isEnabled(){
return false;
}
}
@@ -333,17 +403,11 @@ public class ComposeAutocompleteViewController{
private final TextView text;
private HashtagViewHolder(){
super(new TextView(activity));
super(activity, R.layout.item_autocomplete_hashtag, list);
text=(TextView) itemView;
text.setLayoutParams(new RecyclerView.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, V.dp(48)));
text.setTextAppearance(R.style.m3_title_medium);
text.setTypeface(Typeface.DEFAULT);
text.setSingleLine();
text.setEllipsize(TextUtils.TruncateAt.END);
text.setGravity(Gravity.CENTER_VERTICAL);
text.setPadding(V.dp(16), 0, V.dp(16), 0);
}
@SuppressLint("SetTextI18n")
@Override
public void onBind(Hashtag item){
text.setText("#"+item.name);
@@ -351,7 +415,7 @@ public class ComposeAutocompleteViewController{
@Override
public void onClick(){
completionSelectedListener.accept("#"+item.name);
completionSelectedListener.onCompletionSelected("#"+item.name);
}
}
@@ -395,7 +459,7 @@ public class ComposeAutocompleteViewController{
private EmojiViewHolder(){
super(activity, R.layout.item_autocomplete_user, list);
ava=findViewById(R.id.photo);
name=findViewById(R.id.name);
name=findViewById(R.id.username);
}
@Override
@@ -408,6 +472,7 @@ public class ComposeAutocompleteViewController{
ava.setImageDrawable(null);
}
@SuppressLint("SetTextI18n")
@Override
public void onBind(WrappedEmoji item){
name.setText(":"+item.emoji.shortcode+":");
@@ -415,22 +480,7 @@ public class ComposeAutocompleteViewController{
@Override
public void onClick(){
completionSelectedListener.accept(":"+item.emoji.shortcode+":");
}
}
private static class WrappedAccount{
private Account account;
private CharSequence parsedName;
private CustomEmojiHelper emojiHelper;
private ImageLoaderRequest avaRequest;
public WrappedAccount(Account account){
this.account=account;
parsedName=HtmlParser.parseCustomEmoji(account.displayName, account.emojis);
emojiHelper=new CustomEmojiHelper();
emojiHelper.setText(parsedName);
avaRequest=new UrlImageLoaderRequest(account.avatar, V.dp(50), V.dp(50));
completionSelectedListener.onCompletionSelected(":"+item.emoji.shortcode+":");
}
}
@@ -444,9 +494,15 @@ public class ComposeAutocompleteViewController{
}
}
private enum Mode{
public enum Mode{
USERS,
HASHTAGS,
EMOJIS
}
public interface AutocompleteListener{
void onCompletionSelected(String completion);
void onSetEmojiPanelOpen(boolean open);
void onLaunchAccountSearch();
}
}

View File

@@ -0,0 +1,382 @@
package org.joinmastodon.android.ui.viewcontrollers;
import android.content.Context;
import android.graphics.Canvas;
import android.graphics.Paint;
import android.os.Build;
import android.text.TextUtils;
import android.view.View;
import android.view.ViewGroup;
import android.view.ViewTreeObserver;
import android.view.textclassifier.TextClassificationManager;
import android.view.textclassifier.TextLanguage;
import android.widget.Checkable;
import android.widget.CheckedTextView;
import android.widget.RadioButton;
import android.widget.TextView;
import org.joinmastodon.android.GlobalUserPreferences;
import org.joinmastodon.android.R;
import org.joinmastodon.android.api.MastodonAPIController;
import org.joinmastodon.android.api.session.AccountSession;
import org.joinmastodon.android.api.session.AccountSessionManager;
import org.joinmastodon.android.ui.DividerItemDecoration;
import org.joinmastodon.android.ui.utils.UiUtils;
import org.joinmastodon.android.ui.views.CheckableLinearLayout;
import org.joinmastodon.android.utils.MastodonLanguage;
import org.parceler.Parcel;
import java.util.ArrayList;
import java.util.Comparator;
import java.util.List;
import java.util.Locale;
import java.util.Objects;
import java.util.stream.Collectors;
import androidx.annotation.NonNull;
import androidx.annotation.RequiresApi;
import androidx.recyclerview.widget.LinearLayoutManager;
import androidx.recyclerview.widget.RecyclerView;
import me.grishka.appkit.utils.BindableViewHolder;
import me.grishka.appkit.utils.MergeRecyclerAdapter;
import me.grishka.appkit.utils.V;
import me.grishka.appkit.views.UsableRecyclerView;
public class ComposeLanguageAlertViewController{
private Context context;
private UsableRecyclerView list;
private List<LocaleInfo> allLocales;
private List<SpecialLocaleInfo> specialLocales=new ArrayList<>();
private int selectedIndex=0;
private MastodonLanguage selectedLocale;
private String selectedEncoding;
private MastodonLanguage.LanguageResolver resolver;
public ComposeLanguageAlertViewController(Context context, String preferred, SelectedOption previouslySelected, String postText, MastodonLanguage.LanguageResolver resolver){
this(context, preferred, previouslySelected, postText, resolver, null);
}
public ComposeLanguageAlertViewController(Context context, String preferred, SelectedOption previouslySelected, String postText, MastodonLanguage.LanguageResolver resolver, AccountSession session){
this.context=context;
this.resolver=resolver;
allLocales=MastodonLanguage.allLanguages.stream()
.map(l -> new LocaleInfo(l, l.getDisplayName(context)))
.sorted(Comparator.comparing(a->a.displayName))
.collect(Collectors.toList());
if(!TextUtils.isEmpty(preferred)){
MastodonLanguage lang = resolver.fromOrFallback(preferred);
specialLocales.add(new SpecialLocaleInfo(
lang,
lang.getDisplayName(context),
context.getString(R.string.language_default)
));
}
if(!Locale.getDefault().getLanguage().equals(preferred)){
MastodonLanguage lang = resolver.getDefault();
specialLocales.add(new SpecialLocaleInfo(
lang,
lang.getDisplayName(context),
context.getString(R.string.language_system)
));
}
if(Build.VERSION.SDK_INT>=29 && !TextUtils.isEmpty(postText)){
SpecialLocaleInfo detected=new SpecialLocaleInfo();
detected.displayName=context.getString(R.string.language_detecting);
detected.enabled=false;
specialLocales.add(detected);
detectLanguage(detected, postText);
}
if (session!=null && session.getLocalPreferences().bottomEncoding) {
specialLocales.add(new SpecialLocaleInfo(null, "\uD83E\uDD7A\uD83D\uDC49\uD83D\uDC48", "bottom"));
}
if(previouslySelected!=null){
if(previouslySelected.index!=-1 && ((previouslySelected.index<specialLocales.size() && Objects.equals(previouslySelected.language, specialLocales.get(previouslySelected.index).language)) ||
(previouslySelected.index<specialLocales.size()+allLocales.size() && Objects.equals(previouslySelected.language, allLocales.get(previouslySelected.index-specialLocales.size()).language)))){
selectedIndex=previouslySelected.index;
selectedLocale=previouslySelected.language;
}else{
int i=0;
boolean found=false;
for(SpecialLocaleInfo li:specialLocales){
if(previouslySelected.language != null && previouslySelected.language.equals(li.language)){
selectedLocale=li.language;
selectedIndex=i;
found=true;
break;
}
i++;
}
if(!found){
for(LocaleInfo li:allLocales){
if(li.language.equals(previouslySelected.language)){
selectedLocale=li.language;
selectedIndex=i;
break;
}
i++;
}
}
}
}else{
selectedLocale=specialLocales.get(0).language;
}
list=new UsableRecyclerView(context);
MergeRecyclerAdapter adapter=new MergeRecyclerAdapter();
adapter.addAdapter(new SpecialLanguagesAdapter());
adapter.addAdapter(new AllLocalesAdapter());
list.setAdapter(adapter);
list.setLayoutManager(new LinearLayoutManager(context));
list.addItemDecoration(new DividerItemDecoration(context, R.attr.colorM3Outline, 1, 0, 0, vh->vh.getAbsoluteAdapterPosition()==specialLocales.size()-1));
list.addItemDecoration(new RecyclerView.ItemDecoration(){
private Paint paint=new Paint();
{
paint.setColor(UiUtils.getThemeColor(context, R.attr.colorM3Outline));
paint.setStyle(Paint.Style.STROKE);
paint.setStrokeWidth(V.dp(1));
}
@Override
public void onDrawOver(@NonNull Canvas c, @NonNull RecyclerView parent, @NonNull RecyclerView.State state){
if(parent.canScrollVertically(1)){
float y=parent.getHeight()-paint.getStrokeWidth()/2f;
c.drawLine(0, y, parent.getWidth(), y, paint);
}
if(parent.canScrollVertically(-1)){
float y=paint.getStrokeWidth()/2f;
c.drawLine(0, y, parent.getWidth(), y, paint);
}
}
});
if(previouslySelected!=null && selectedIndex>0){
list.getViewTreeObserver().addOnPreDrawListener(new ViewTreeObserver.OnPreDrawListener(){
@Override
public boolean onPreDraw(){
list.getViewTreeObserver().removeOnPreDrawListener(this);
if(list.findViewHolderForAdapterPosition(selectedIndex)==null)
list.scrollToPosition(selectedIndex);
return true;
}
});
}
}
@RequiresApi(api = Build.VERSION_CODES.Q)
private void detectLanguage(SpecialLocaleInfo info, String text){
MastodonAPIController.runInBackground(()->{
TextLanguage lang=context.getSystemService(TextClassificationManager.class).getTextClassifier().detectLanguage(new TextLanguage.Request.Builder(text).build());
list.post(()->{
SpecialLanguageViewHolder holder=(SpecialLanguageViewHolder) list.findViewHolderForAdapterPosition(specialLocales.indexOf(info));
if(lang.getLocaleHypothesisCount()==0 || lang.getConfidenceScore(lang.getLocale(0))<0.75f){
info.displayName=context.getString(R.string.language_cant_detect);
}else{
MastodonLanguage language = resolver.fromOrFallback(lang.getLocale(0).toLanguageTag());
info.language=language;
info.displayName=language.getDisplayName(context);
info.title=context.getString(R.string.language_detected);
info.enabled=true;
if(holder!=null)
UiUtils.beginLayoutTransition(holder.view);
}
if(holder!=null)
holder.rebind();
});
});
}
public View getView(){
return list;
}
// Needed because in some languages (e.g. Slavic ones) these names returned by the system start with a lowercase letter
private String capitalizeLanguageName(String name){
return name.substring(0, 1).toUpperCase(Locale.getDefault())+name.substring(1);
}
public SelectedOption getSelectedOption(){
return new SelectedOption(selectedIndex, selectedLocale, selectedEncoding);
}
private void selectItem(int index){
if(index==selectedIndex)
return;
if(selectedIndex!=-1){
RecyclerView.ViewHolder holder=list.findViewHolderForAdapterPosition(selectedIndex);
if(holder!=null && holder.itemView instanceof Checkable checkable)
checkable.setChecked(false);
}
RecyclerView.ViewHolder holder=list.findViewHolderForAdapterPosition(index);
if(holder!=null && holder.itemView instanceof Checkable checkable)
checkable.setChecked(true);
selectedIndex=index;
}
private class AllLocalesAdapter extends RecyclerView.Adapter<SimpleLanguageViewHolder>{
@NonNull
@Override
public SimpleLanguageViewHolder onCreateViewHolder(@NonNull ViewGroup parent, int viewType){
return new SimpleLanguageViewHolder();
}
@Override
public void onBindViewHolder(@NonNull SimpleLanguageViewHolder holder, int position){
holder.bind(allLocales.get(position));
}
@Override
public int getItemCount(){
return allLocales.size();
}
@Override
public int getItemViewType(int position){
return 1;
}
}
private class SimpleLanguageViewHolder extends BindableViewHolder<LocaleInfo> implements UsableRecyclerView.Clickable{
private final CheckedTextView text;
public SimpleLanguageViewHolder(){
super(context, R.layout.item_alert_single_choice_1line, list);
text=(CheckedTextView) itemView;
text.setCompoundDrawablesRelativeWithIntrinsicBounds(new RadioButton(context).getButtonDrawable(), null, null, null);
}
@Override
public void onBind(LocaleInfo item){
text.setText(item.displayName);
text.setChecked(selectedIndex==getAbsoluteAdapterPosition());
}
@Override
public void onClick(){
selectItem(getAbsoluteAdapterPosition());
selectedLocale=item.language;
selectedEncoding=null;
}
}
private class SpecialLanguagesAdapter extends RecyclerView.Adapter<SpecialLanguageViewHolder>{
@NonNull
@Override
public SpecialLanguageViewHolder onCreateViewHolder(@NonNull ViewGroup parent, int viewType){
return new SpecialLanguageViewHolder();
}
@Override
public void onBindViewHolder(@NonNull SpecialLanguageViewHolder holder, int position){
holder.bind(specialLocales.get(position));
}
@Override
public int getItemCount(){
return specialLocales.size();
}
@Override
public int getItemViewType(int position){
return 2;
}
}
private class SpecialLanguageViewHolder extends BindableViewHolder<SpecialLocaleInfo> implements UsableRecyclerView.DisableableClickable{
private final TextView text, title;
private final CheckableLinearLayout view;
public SpecialLanguageViewHolder(){
super(context, R.layout.item_alert_single_choice_2lines, list);
text=findViewById(R.id.text);
title=findViewById(R.id.title);
view=((CheckableLinearLayout) itemView);
findViewById(R.id.radiobutton).setBackground(new RadioButton(context).getButtonDrawable());
}
@Override
public void onBind(SpecialLocaleInfo item){
text.setText(item.displayName);
if(!TextUtils.isEmpty(item.title)){
title.setVisibility(View.VISIBLE);
title.setText(item.title);
}else{
title.setVisibility(View.GONE);
}
text.setEnabled(item.enabled);
view.setEnabled(item.enabled);
view.setChecked(selectedIndex==getAbsoluteAdapterPosition());
}
@Override
public void onClick(){
selectItem(getAbsoluteAdapterPosition());
selectedLocale=item.language;
selectedEncoding = item.title.equals("bottom") ? "bottom" : null;
}
@Override
public boolean isEnabled(){
return item.enabled;
}
}
private static class LocaleInfo{
public final MastodonLanguage language;
public final String displayName;
private LocaleInfo(MastodonLanguage language, String displayName){
this.language=language;
this.displayName=displayName;
}
}
private static class SpecialLocaleInfo{
public SpecialLocaleInfo() {}
public SpecialLocaleInfo(MastodonLanguage lang, String displayName, String title) {
this.language = lang;
this.displayName = displayName;
this.title = title;
}
public MastodonLanguage language;
public String displayName;
public String title;
public boolean enabled=true;
}
@Parcel
public static class SelectedOption {
public int index;
public MastodonLanguage language;
public String encoding;
public SelectedOption(){}
public SelectedOption(int index, MastodonLanguage language, String encoding) {
this.index = index;
this.language = language;
this.encoding = encoding;
}
public SelectedOption(int index, MastodonLanguage language) {
this(index, language, null);
}
public SelectedOption(MastodonLanguage language) {
this(-1, language, null);
}
}
}

View File

@@ -0,0 +1,793 @@
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<DraftMediaAttachment> 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<Parcelable> 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();
fragment.updateSensitive();
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_fluent_arrow_counterclockwise_24_regular);
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;
draft.mimeType="image/jpeg";
draft.fileSize=2473276;
draft.state=AttachmentUploadState.PROCESSING;
attachmentsView.addView(createMediaAttachmentView(draft));
attachments.add(draft);
attachmentsScroller.setVisibility(View.VISIBLE);
updateMediaAttachmentsLayout();
finishMediaAttachmentUpload(draft);
}
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){
if(fragment.getActivity()==null)
return;
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_fluent_arrow_counterclockwise_24_regular);
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();
fragment.updateSensitive();
}
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_fluent_edit_24_regular);
attachment.removeButton.setImageResource(R.drawable.ic_fluent_delete_24_regular);
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;
}
public boolean areAnyAttachmentsNotDone() {
for(DraftMediaAttachment att:attachments){
if(att.state!=AttachmentUploadState.DONE)
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<String> getAttachmentIDs(){
return attachments.stream().map(a->a.serverAttachment.id).collect(Collectors.toList());
}
public boolean isEmpty(){
return attachments.isEmpty();
}
public boolean canAddMoreAttachments(){
return attachments.size()<MAX_ATTACHMENTS;
}
public int getMissingAltTextAttachmentCount(){
int count=0;
for(DraftMediaAttachment att:attachments){
if(TextUtils.isEmpty(att.description))
count++;
}
return count;
}
public boolean areAllAttachmentsImages(){
for(DraftMediaAttachment att:attachments){
if((att.mimeType==null && att.serverAttachment.type==Attachment.Type.IMAGE) || (att.mimeType!=null && !att.mimeType.startsWith("image/")))
return false;
}
return true;
}
public int getMaxAttachments(){
return MAX_ATTACHMENTS;
}
public int getNonDoneAttachmentCount(){
int nonDoneAttachmentCount=0;
for(DraftMediaAttachment att:attachments){
if(att.state!=AttachmentUploadState.DONE)
nonDoneAttachmentCount++;
}
return nonDoneAttachmentCount;
}
public void saveAltTextsBeforePublishing(Runnable onSuccess, Consumer<ErrorResponse> onError){
ArrayList<UpdateAttachment> 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<Parcelable> 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<View, Animator> 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();
}
}
}

View File

@@ -0,0 +1,413 @@
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<DraftPollOption> 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(UiUtils.formatDuration(fragment.getContext(), 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<DraftPollOption> oldOptions=new ArrayList<>(pollOptions);
pollOptions.clear();
for(DraftPollOption oldOpt:oldOptions){
DraftPollOption opt=createDraftPollOption(false);
opt.edit.setText(oldOpt.edit.getText());
}
updatePollOptionHints();
pollDurationValue.setText(UiUtils.formatDuration(fragment.getContext(), 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(UiUtils.formatDuration(fragment.getContext(), pollDuration));
pollIsMultipleChoice=fragment.editingStatus.poll.multiple;
pollStyleValue.setText(pollIsMultipleChoice ? R.string.compose_poll_multiple_choice : R.string.compose_poll_single_choice);
}else{
pollDurationValue.setText(UiUtils.formatDuration(fragment.getContext(), 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()<maxPollOptions);
option.edit.addTextChangedListener(new LengthLimitHighlighter(fragment.getActivity(), maxPollOptionLength).setListener(isOverLimit->{
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;i<POLL_LENGTH_OPTIONS.length;i++){
int l=POLL_LENGTH_OPTIONS[i];
options[i]=UiUtils.formatDuration(fragment.getContext(), l);
if(l==pollDuration)
selectedOption=i;
}
int[] chosenOption={0};
new M3AlertDialogBuilder(fragment.getActivity())
.setSingleChoiceItems(options, selectedOption, (dialog, which)->chosenOption[0]=which)
.setTitle(R.string.poll_length)
.setPositiveButton(R.string.ok, (dialog, which)->{
pollDuration=POLL_LENGTH_OPTIONS[chosenOption[0]];
pollDurationValue.setText(UiUtils.formatDuration(fragment.getContext(), pollDuration));
})
.setNegativeButton(R.string.cancel, null)
.show();
}
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<String> 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()<maxPollOptions);
return;
}
ReorderableLinearLayout.OnDragListener.super.onDragEnd(view);
int color=UiUtils.getThemeColor(fragment.getActivity(), R.attr.colorM3OnSurface);
ObjectAnimator anim=ObjectAnimator.ofArgb(dpo.edit, "backgroundColor", color & 0x29ffffff, color & 0xffffff);
anim.setDuration(200);
anim.setInterpolator(CubicBezierInterpolator.DEFAULT);
anim.addListener(new AnimatorListenerAdapter(){
@Override
public void onAnimationEnd(Animator animation){
fragment.mainLayout.setClipChildren(true);
}
});
anim.start();
}
@Override
public void onDragMove(View view){
if(pollOptions.size()<3)
return;
DraftPollOption dpo=(DraftPollOption) view.getTag();
// Yes, I don't like this either.
float draggerX=view.getX()+dpo.dragger.getX()+pollOptionsView.getX();
float deleteButtonX=pollSettingsView.getX()+deletePollOptionBtn.getX();
rect1.set(deleteButtonX, pollOptionsView.getHeight(), deleteButtonX+deletePollOptionBtn.getWidth(), pollWrap.getHeight());
rect2.set(draggerX, view.getY(), draggerX+dpo.dragger.getWidth(), view.getY()+view.getHeight());
boolean newOverDelete=rect1.intersect(rect2);
if(newOverDelete!=isOverDelete){
if(deletionStateAnimator!=null)
deletionStateAnimator.cancel();
isOverDelete=newOverDelete;
if(newOverDelete)
view.performHapticFeedback(HapticFeedbackConstants.KEYBOARD_TAP);
dpo.view.setForeground(fragment.getResources().getDrawable(newOverDelete || dpo.edit.length()>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();
}
}
}
}

View File

@@ -0,0 +1,353 @@
package org.joinmastodon.android.ui.viewholders;
import android.annotation.SuppressLint;
import android.app.Activity;
import android.app.Fragment;
import android.content.Intent;
import android.graphics.drawable.Animatable;
import android.graphics.drawable.Drawable;
import android.os.Bundle;
import android.text.SpannableStringBuilder;
import android.text.style.TypefaceSpan;
import android.view.Menu;
import android.view.MenuItem;
import android.view.View;
import android.view.ViewGroup;
import android.widget.CheckBox;
import android.widget.FrameLayout;
import android.widget.ImageView;
import android.widget.PopupMenu;
import android.widget.ProgressBar;
import android.widget.RadioButton;
import android.widget.TextView;
import org.joinmastodon.android.R;
import org.joinmastodon.android.api.requests.accounts.SetAccountFollowed;
import org.joinmastodon.android.api.session.AccountSessionManager;
import org.joinmastodon.android.fragments.ProfileFragment;
import org.joinmastodon.android.fragments.report.ReportReasonChoiceFragment;
import org.joinmastodon.android.model.Account;
import org.joinmastodon.android.model.Relationship;
import org.joinmastodon.android.model.viewmodel.AccountViewModel;
import org.joinmastodon.android.ui.OutlineProviders;
import org.joinmastodon.android.ui.text.HtmlParser;
import org.joinmastodon.android.ui.utils.UiUtils;
import org.joinmastodon.android.ui.views.CheckableRelativeLayout;
import org.joinmastodon.android.ui.views.ProgressBarButton;
import org.parceler.Parcels;
import java.util.HashMap;
import java.util.Objects;
import java.util.Optional;
import java.util.function.Consumer;
import me.grishka.appkit.Nav;
import me.grishka.appkit.api.Callback;
import me.grishka.appkit.api.ErrorResponse;
import me.grishka.appkit.imageloader.ImageLoaderViewHolder;
import me.grishka.appkit.utils.BindableViewHolder;
import me.grishka.appkit.views.UsableRecyclerView;
public class AccountViewHolder extends BindableViewHolder<AccountViewModel> implements ImageLoaderViewHolder, UsableRecyclerView.Clickable, UsableRecyclerView.LongClickable{
private final TextView name, username, followers, pronouns, bio;
private final ImageView avatar;
private final FrameLayout accessory;
private final ProgressBarButton button;
private final PopupMenu contextMenu;
private final View menuAnchor;
private final TypefaceSpan mediumSpan=new TypefaceSpan("sans-serif-medium");
private final CheckableRelativeLayout view;
private final View checkbox;
private final ProgressBar actionProgress;
private final String accountID;
private final Fragment fragment;
private final HashMap<String, Relationship> relationships;
private Consumer<AccountViewHolder> onClick;
private AccessoryType accessoryType;
private boolean showBio;
private boolean checked;
public AccountViewHolder(Fragment fragment, ViewGroup list, HashMap<String, Relationship> relationships){
super(fragment.getActivity(), R.layout.item_account_list, list);
this.fragment=fragment;
this.accountID=Objects.requireNonNull(fragment.getArguments().getString("account"));
this.relationships=relationships;
view=(CheckableRelativeLayout) itemView;
name=findViewById(R.id.name);
username=findViewById(R.id.username);
avatar=findViewById(R.id.avatar);
accessory=findViewById(R.id.accessory);
button=findViewById(R.id.button);
menuAnchor=findViewById(R.id.menu_anchor);
followers=findViewById(R.id.followers_count);
pronouns=findViewById(R.id.pronouns);
bio=findViewById(R.id.bio);
checkbox=findViewById(R.id.checkbox);
actionProgress=findViewById(R.id.action_progress);
avatar.setOutlineProvider(OutlineProviders.roundedRect(10));
avatar.setClipToOutline(true);
button.setOnClickListener(this::onButtonClick);
accessory.setOnClickListener(v -> button.performClick());
contextMenu=new PopupMenu(fragment.getActivity(), menuAnchor);
contextMenu.inflate(R.menu.profile);
contextMenu.setOnMenuItemClickListener(this::onContextMenuItemSelected);
setStyle(AccessoryType.BUTTON, false);
}
@SuppressLint("SetTextI18n")
@Override
public void onBind(AccountViewModel item){
name.setText(item.parsedName);
username.setText("@"+item.account.acct);
long num=item.account.followersCount > -1 ? item.account.followersCount : item.account.followingCount;
String followersStr = fragment.getResources().getQuantityString(item.account.followersCount > -1
? R.plurals.x_followers : R.plurals.x_following, num>1000 ? 999 : (int)num);
String followersNum=UiUtils.abbreviateNumber(num);
int index=followersStr.indexOf("%,d");
followersStr=followersStr.replace("%,d", followersNum);
SpannableStringBuilder followersFormatted=new SpannableStringBuilder(followersStr);
if(index!=-1){
followersFormatted.setSpan(mediumSpan, index, index+followersNum.length(), 0);
}
if (item.account.followingCount > -1 || item.account.followersCount > -1) {
followers.setVisibility(View.VISIBLE);
followers.setText(followersFormatted);
} else {
followers.setVisibility(View.GONE);
}
// you know what's cooler than followers or verified links? yep. pronouns
Optional<String> pronounsString = UiUtils.extractPronouns(itemView.getContext(), item.account);
pronouns.setVisibility(pronounsString.isPresent() ? View.VISIBLE : View.GONE);
pronounsString.ifPresent(p -> HtmlParser.setTextWithCustomEmoji(pronouns, p, item.account.emojis));
/* unused in megalodon
boolean hasVerifiedLink=item.verifiedLink!=null;
if(!hasVerifiedLink)
verifiedLink.setText(R.string.no_verified_link);
else
verifiedLink.setText(item.verifiedLink);
verifiedLink.setCompoundDrawablesRelativeWithIntrinsicBounds(hasVerifiedLink ? R.drawable.ic_fluent_checkmark_16_filled : R.drawable.ic_help_16px, 0, 0, 0);
int tintColor=UiUtils.getThemeColor(fragment.getActivity(), hasVerifiedLink ? R.attr.colorM3Primary : R.attr.colorM3Secondary);
verifiedLink.setTextColor(tintColor);
verifiedLink.setCompoundDrawableTintList(ColorStateList.valueOf(tintColor));
*/
bindRelationship();
if(showBio){
bio.setText(item.parsedBio);
}
}
public void bindRelationship(){
if(relationships==null || accessoryType!=AccessoryType.BUTTON)
return;
Relationship rel=relationships.get(item.account.id);
if(rel==null || AccountSessionManager.getInstance().isSelf(accountID, item.account)){
button.setVisibility(View.GONE);
}else{
button.setVisibility(View.VISIBLE);
UiUtils.setRelationshipToActionButtonM3(rel, button);
}
}
@Override
public void setImage(int index, Drawable image){
if(index==0){
avatar.setImageDrawable(image);
}else{
item.emojiHelper.setImageDrawable(index-1, image);
name.invalidate();
bio.invalidate();
}
if(image instanceof Animatable a && !a.isRunning())
a.start();
}
@Override
public void clearImage(int index){
if(index==0){
avatar.setImageResource(R.drawable.image_placeholder);
}else{
setImage(index, null);
}
}
@Override
public void onClick(){
if(onClick!=null){
onClick.accept(this);
return;
}
Bundle args=new Bundle();
args.putString("account", accountID);
args.putParcelable("profileAccount", Parcels.wrap(item.account));
Nav.go(fragment.getActivity(), ProfileFragment.class, args);
}
@Override
public boolean onLongClick(){
return false;
}
@Override
public boolean onLongClick(float x, float y){
if(relationships==null)
return false;
Relationship relationship=relationships.get(item.account.id);
if(relationship==null)
return false;
Menu menu=contextMenu.getMenu();
Account account=item.account;
menu.findItem(R.id.share).setTitle(fragment.getString(R.string.share_user, account.getDisplayUsername()));
menu.findItem(R.id.mute).setTitle(fragment.getString(relationship.muting ? R.string.unmute_user : R.string.mute_user, account.getDisplayUsername()));
menu.findItem(R.id.block).setTitle(fragment.getString(relationship.blocking ? R.string.unblock_user : R.string.block_user, account.getDisplayUsername()));
menu.findItem(R.id.report).setTitle(fragment.getString(R.string.report_user, account.getDisplayUsername()));
MenuItem hideBoosts=menu.findItem(R.id.hide_boosts);
if(relationship.following){
hideBoosts.setTitle(fragment.getString(relationship.showingReblogs ? R.string.hide_boosts_from_user : R.string.show_boosts_from_user, account.getDisplayUsername()));
hideBoosts.setVisible(true);
}else{
hideBoosts.setVisible(false);
}
MenuItem blockDomain=menu.findItem(R.id.block_domain);
if(!account.isLocal()){
blockDomain.setTitle(fragment.getString(relationship.domainBlocking ? R.string.unblock_domain : R.string.block_domain, account.getDomain()));
blockDomain.setVisible(true);
}else{
blockDomain.setVisible(false);
}
menuAnchor.setTranslationX(x);
menuAnchor.setTranslationY(y);
contextMenu.show();
return true;
}
private void onButtonClick(View v){
if(relationships==null)
return;
itemView.setHasTransientState(true);
UiUtils.performAccountAction((Activity) v.getContext(), item.account, accountID, relationships.get(item.account.id), button, this::setActionProgressVisible, rel->{
itemView.setHasTransientState(false);
relationships.put(item.account.id, rel);
bindRelationship();
});
}
private void setActionProgressVisible(boolean visible){
if(visible)
actionProgress.setIndeterminateTintList(button.getTextColors());
// TODO button.setTextVisible(!visible);
actionProgress.setVisibility(visible ? View.VISIBLE : View.GONE);
button.setClickable(!visible);
}
private boolean onContextMenuItemSelected(MenuItem item){
Relationship relationship=relationships.get(this.item.account.id);
if(relationship==null)
return false;
Account account=this.item.account;
int id=item.getItemId();
if(id==R.id.share){
Intent intent=new Intent(Intent.ACTION_SEND);
intent.setType("text/plain");
intent.putExtra(Intent.EXTRA_TEXT, account.url);
fragment.startActivity(Intent.createChooser(intent, item.getTitle()));
}else if(id==R.id.mute){
UiUtils.confirmToggleMuteUser(fragment.getActivity(), accountID, account, relationship.muting, this::updateRelationship);
}else if(id==R.id.block){
UiUtils.confirmToggleBlockUser(fragment.getActivity(), accountID, account, relationship.blocking, this::updateRelationship);
}else if(id==R.id.report){
Bundle args=new Bundle();
args.putString("account", accountID);
args.putParcelable("reportAccount", Parcels.wrap(account));
args.putParcelable("relationship", Parcels.wrap(relationship));
Nav.go(fragment.getActivity(), ReportReasonChoiceFragment.class, args);
}else if(id==R.id.open_in_browser){
UiUtils.launchWebBrowser(fragment.getActivity(), account.url);
}else if(id==R.id.block_domain){
UiUtils.confirmToggleBlockDomain(fragment.getActivity(), accountID, account.getDomain(), relationship.domainBlocking, ()->{
relationship.domainBlocking=!relationship.domainBlocking;
bindRelationship();
});
}else if(id==R.id.hide_boosts){
new SetAccountFollowed(account.id, true, !relationship.showingReblogs)
.setCallback(new Callback<>(){
@Override
public void onSuccess(Relationship result){
relationships.put(AccountViewHolder.this.item.account.id, result);
bindRelationship();
}
@Override
public void onError(ErrorResponse error){
error.showToast(fragment.getActivity());
}
})
.wrapProgress(fragment.getActivity(), R.string.loading, false)
.exec(accountID);
}
return true;
}
private void updateRelationship(Relationship r){
relationships.put(item.account.id, r);
bindRelationship();
}
public void setOnClickListener(Consumer<AccountViewHolder> listener){
onClick=listener;
}
public void setStyle(AccessoryType accessoryType, boolean showBio){
if(accessoryType!=this.accessoryType){
this.accessoryType=accessoryType;
switch(accessoryType){
case NONE -> {
button.setVisibility(View.GONE);
checkbox.setVisibility(View.GONE);
}
case CHECKBOX -> {
button.setVisibility(View.GONE);
checkbox.setVisibility(View.VISIBLE);
checkbox.setBackground(new CheckBox(checkbox.getContext()).getButtonDrawable());
}
case RADIOBUTTON -> {
button.setVisibility(View.GONE);
checkbox.setVisibility(View.VISIBLE);
checkbox.setBackground(new RadioButton(checkbox.getContext()).getButtonDrawable());
}
case BUTTON -> {
button.setVisibility(View.VISIBLE);
checkbox.setVisibility(View.GONE);
}
}
view.setCheckable(accessoryType==AccessoryType.CHECKBOX || accessoryType==AccessoryType.RADIOBUTTON);
}
this.showBio=showBio;
bio.setVisibility(showBio ? View.VISIBLE : View.GONE);
}
public void setChecked(boolean checked){
this.checked=checked;
view.setChecked(checked);
}
public enum AccessoryType{
NONE,
BUTTON,
CHECKBOX,
RADIOBUTTON
}
}

View File

@@ -0,0 +1,23 @@
package org.joinmastodon.android.ui.viewholders;
import android.content.Context;
import android.view.ViewGroup;
import org.joinmastodon.android.R;
import org.joinmastodon.android.model.viewmodel.CheckableListItem;
import org.joinmastodon.android.ui.views.CheckableLinearLayout;
public abstract class CheckableListItemViewHolder extends ListItemViewHolder<CheckableListItem<?>>{
protected final CheckableLinearLayout checkableLayout;
public CheckableListItemViewHolder(Context context, ViewGroup parent){
super(context, R.layout.item_generic_list_checkable, parent);
checkableLayout=(CheckableLinearLayout) itemView;
}
@Override
public void onBind(CheckableListItem<?> item){
super.onBind(item);
checkableLayout.setChecked(item.checked);
}
}

View File

@@ -0,0 +1,25 @@
package org.joinmastodon.android.ui.viewholders;
import android.content.Context;
import android.view.View;
import android.view.ViewGroup;
import android.widget.CheckBox;
import android.widget.CompoundButton;
import android.widget.LinearLayout;
import android.widget.RadioButton;
import me.grishka.appkit.utils.V;
public class CheckboxOrRadioListItemViewHolder extends CheckableListItemViewHolder{
public CheckboxOrRadioListItemViewHolder(Context context, ViewGroup parent, boolean radio){
super(context, parent);
View iconView=new View(context);
iconView.setDuplicateParentStateEnabled(true);
CompoundButton terribleHack=radio ? new RadioButton(context) : new CheckBox(context);
iconView.setBackground(terribleHack.getButtonDrawable());
LinearLayout.LayoutParams lp=new LinearLayout.LayoutParams(V.dp(32), V.dp(32));
lp.setMarginStart(V.dp(12));
lp.setMarginEnd(V.dp(4));
checkableLayout.addView(iconView, lp);
}
}

View File

@@ -0,0 +1,36 @@
package org.joinmastodon.android.ui.viewholders;
import android.annotation.SuppressLint;
import android.view.ViewGroup;
import android.widget.TextView;
import org.joinmastodon.android.R;
import org.joinmastodon.android.model.Instance;
import org.joinmastodon.android.ui.text.HtmlParser;
import me.grishka.appkit.utils.BindableViewHolder;
public class InstanceRuleViewHolder extends BindableViewHolder<Instance.Rule>{
private final TextView text, number;
private int position;
public InstanceRuleViewHolder(ViewGroup parent){
super(parent.getContext(), R.layout.item_server_rule, parent);
text=findViewById(R.id.text);
number=findViewById(R.id.number);
}
public void setPosition(int position){
this.position=position;
}
@SuppressLint("DefaultLocale")
@Override
public void onBind(Instance.Rule item){
if(item.parsedText==null){
item.parsedText=HtmlParser.parseLinks(item.text);
}
text.setText(item.parsedText);
number.setText(String.format("%d", position+1));
}
}

View File

@@ -0,0 +1,80 @@
package org.joinmastodon.android.ui.viewholders;
import android.content.Context;
import android.content.res.ColorStateList;
import android.text.TextUtils;
import android.view.View;
import android.view.ViewGroup;
import android.widget.ImageView;
import android.widget.LinearLayout;
import android.widget.TextView;
import org.joinmastodon.android.R;
import org.joinmastodon.android.model.viewmodel.ListItem;
import org.joinmastodon.android.ui.utils.UiUtils;
import me.grishka.appkit.utils.BindableViewHolder;
import me.grishka.appkit.utils.V;
import me.grishka.appkit.views.UsableRecyclerView;
public abstract class ListItemViewHolder<T extends ListItem<?>> extends BindableViewHolder<T> implements UsableRecyclerView.DisableableClickable{
protected final TextView title;
protected final TextView subtitle;
protected final ImageView icon;
protected final LinearLayout view;
public ListItemViewHolder(Context context, int layout, ViewGroup parent){
super(context, layout, parent);
title=findViewById(R.id.title);
subtitle=findViewById(R.id.subtitle);
icon=findViewById(R.id.icon);
view=(LinearLayout) itemView;
}
@Override
public void onBind(T item){
if(TextUtils.isEmpty(item.title))
title.setText(item.titleRes);
else
title.setText(item.title);
if(TextUtils.isEmpty(item.subtitle) && item.subtitleRes==0){
subtitle.setVisibility(View.GONE);
title.setMaxLines(2);
view.setMinimumHeight(V.dp(56));
}else{
subtitle.setVisibility(View.VISIBLE);
title.setMaxLines(1);
view.setMinimumHeight(V.dp(72));
if(TextUtils.isEmpty(item.subtitle))
subtitle.setText(item.subtitleRes);
else
subtitle.setText(item.subtitle);
}
if(item.iconRes!=0){
icon.setVisibility(View.VISIBLE);
icon.setImageResource(item.iconRes);
}else{
icon.setVisibility(View.GONE);
}
if(item.colorOverrideAttr!=0){
int color=UiUtils.getThemeColor(view.getContext(), item.colorOverrideAttr);
title.setTextColor(color);
icon.setImageTintList(ColorStateList.valueOf(color));
}
view.setAlpha(item.isEnabled ? 1 : .4f);
}
@Override
public boolean isEnabled(){
return item.isEnabled;
}
@Override
public void onClick(){
item.onClick.run();
}
}

View File

@@ -0,0 +1,13 @@
package org.joinmastodon.android.ui.viewholders;
import android.content.Context;
import android.view.ViewGroup;
import org.joinmastodon.android.R;
import org.joinmastodon.android.model.viewmodel.ListItem;
public class SimpleListItemViewHolder extends ListItemViewHolder<ListItem<?>>{
public SimpleListItemViewHolder(Context context, ViewGroup parent){
super(context, R.layout.item_generic_list, parent);
}
}

View File

@@ -0,0 +1,43 @@
package org.joinmastodon.android.ui.viewholders;
import android.content.Context;
import android.view.Gravity;
import android.view.ViewGroup;
import android.widget.LinearLayout;
import org.joinmastodon.android.model.viewmodel.CheckableListItem;
import org.joinmastodon.android.ui.views.M3Switch;
import me.grishka.appkit.utils.V;
public class SwitchListItemViewHolder extends CheckableListItemViewHolder{
private final M3Switch sw;
private boolean ignoreListener;
public SwitchListItemViewHolder(Context context, ViewGroup parent){
super(context, parent);
sw=new M3Switch(context);
LinearLayout.LayoutParams lp=new LinearLayout.LayoutParams(V.dp(52), V.dp(32));
lp.gravity=Gravity.TOP;
lp.setMarginStart(V.dp(16));
checkableLayout.addView(sw, lp);
sw.setOnCheckedChangeListener((buttonView, isChecked)->{
if(ignoreListener)
return;
if(item.checkedChangeListener!=null)
item.checkedChangeListener.accept(isChecked);
else
item.checked=isChecked;
});
sw.setClickable(true);
}
@Override
public void onBind(CheckableListItem<?> item){
super.onBind(item);
ignoreListener=true;
sw.setChecked(item.checked);
sw.setEnabled(item.isEnabled);
ignoreListener=false;
}
}

View File

@@ -0,0 +1,39 @@
package org.joinmastodon.android.ui.views;
import android.content.Context;
import android.graphics.drawable.Drawable;
import android.util.AttributeSet;
import android.widget.TextView;
import org.joinmastodon.android.R;
import org.joinmastodon.android.ui.utils.UiUtils;
public class CheckIconSelectableTextView extends TextView{
private boolean currentlySelected;
public CheckIconSelectableTextView(Context context){
this(context, null);
}
public CheckIconSelectableTextView(Context context, AttributeSet attrs){
this(context, attrs, 0);
}
public CheckIconSelectableTextView(Context context, AttributeSet attrs, int defStyle){
super(context, attrs, defStyle);
}
@Override
protected void drawableStateChanged(){
super.drawableStateChanged();
if(currentlySelected==isSelected())
return;
currentlySelected=isSelected();
Drawable start=currentlySelected ? getResources().getDrawable(R.drawable.ic_baseline_check_18, getContext().getTheme()).mutate() : null;
if(start!=null)
start.setTint(UiUtils.getThemeColor(getContext(), R.attr.colorM3OnSurface));
Drawable end=getCompoundDrawablesRelative()[2];
setCompoundDrawablesRelativeWithIntrinsicBounds(start, null, end, null);
}
}

View File

@@ -0,0 +1,58 @@
package org.joinmastodon.android.ui.views;
import android.content.Context;
import android.util.AttributeSet;
import android.view.accessibility.AccessibilityNodeInfo;
import android.widget.Checkable;
import android.widget.LinearLayout;
public class CheckableLinearLayout extends LinearLayout implements Checkable{
private boolean checked;
private static final int[] CHECKED_STATE_SET = {
android.R.attr.state_checked
};
public CheckableLinearLayout(Context context){
this(context, null);
}
public CheckableLinearLayout(Context context, AttributeSet attrs){
this(context, attrs, 0);
}
public CheckableLinearLayout(Context context, AttributeSet attrs, int defStyle){
super(context, attrs, defStyle);
}
@Override
public void setChecked(boolean checked){
this.checked=checked;
refreshDrawableState();
}
@Override
public boolean isChecked(){
return checked;
}
@Override
public void toggle(){
setChecked(!checked);
}
@Override
protected int[] onCreateDrawableState(int extraSpace) {
final int[] drawableState = super.onCreateDrawableState(extraSpace + 1);
if (isChecked()) {
mergeDrawableStates(drawableState, CHECKED_STATE_SET);
}
return drawableState;
}
@Override
public void onInitializeAccessibilityNodeInfo(AccessibilityNodeInfo info){
super.onInitializeAccessibilityNodeInfo(info);
info.setCheckable(true);
info.setChecked(checked);
}
}

View File

@@ -7,7 +7,7 @@ import android.widget.Checkable;
import android.widget.RelativeLayout;
public class CheckableRelativeLayout extends RelativeLayout implements Checkable{
private boolean checked, checkable = true;
private boolean checked, checkable=true;
private static final int[] CHECKED_STATE_SET = {
android.R.attr.state_checked
};
@@ -30,10 +30,6 @@ public class CheckableRelativeLayout extends RelativeLayout implements Checkable
refreshDrawableState();
}
public void setCheckable(boolean checkable) {
this.checkable = checkable;
}
@Override
public boolean isChecked(){
return checked;
@@ -44,6 +40,10 @@ public class CheckableRelativeLayout extends RelativeLayout implements Checkable
setChecked(!checked);
}
public void setCheckable(boolean checkable){
this.checkable=checkable;
}
@Override
protected int[] onCreateDrawableState(int extraSpace) {
final int[] drawableState = super.onCreateDrawableState(extraSpace + 1);

View File

@@ -0,0 +1,5 @@
package org.joinmastodon.android.ui.views;
public interface ChildDrawingOrderCallback{
int getChildDrawingOrder(int childCount, int drawingPosition);
}

View File

@@ -21,6 +21,7 @@ import androidx.annotation.RequiresApi;
public class ComposeEditText extends EditText{
private SelectionListener selectionListener;
private InputConnection currentInputConnection;
public ComposeEditText(Context context){
super(context);
@@ -49,15 +50,19 @@ public class ComposeEditText extends EditText{
this.selectionListener=selectionListener;
}
public InputConnection getCurrentInputConnection(){
return currentInputConnection;
}
// Support receiving images from keyboards
@Override
public InputConnection onCreateInputConnection(EditorInfo outAttrs){
final InputConnection ic=super.onCreateInputConnection(outAttrs);
if(Build.VERSION.SDK_INT>=Build.VERSION_CODES.N_MR1){
outAttrs.contentMimeTypes=selectionListener.onGetAllowedMediaMimeTypes();
return new MediaAcceptingInputConnection(ic);
return currentInputConnection=new MediaAcceptingInputConnection(ic);
}
return ic;
return currentInputConnection=ic;
}
// Support pasting images

View File

@@ -8,7 +8,7 @@ import android.widget.ImageView;
import androidx.annotation.Nullable;
public class CoverImageView extends ImageView{
private float imageTranslationY, imageScale=1f;
private float imageTranslationY;
public CoverImageView(Context context){
super(context);
@@ -30,8 +30,7 @@ public class CoverImageView extends ImageView{
canvas.restore();
}
public void setTransform(float transY, float scale){
public void setTransform(float transY){
imageTranslationY=transY;
imageScale=scale;
}
}

View File

@@ -0,0 +1,33 @@
package org.joinmastodon.android.ui.views;
import android.content.Context;
import android.util.AttributeSet;
import android.widget.LinearLayout;
public class CustomDrawingOrderLinearLayout extends LinearLayout{
private ChildDrawingOrderCallback drawingOrderCallback;
public CustomDrawingOrderLinearLayout(Context context){
this(context, null);
}
public CustomDrawingOrderLinearLayout(Context context, AttributeSet attrs){
this(context, attrs, 0);
}
public CustomDrawingOrderLinearLayout(Context context, AttributeSet attrs, int defStyle){
super(context, attrs, defStyle);
setChildrenDrawingOrderEnabled(true);
}
@Override
protected int getChildDrawingOrder(int childCount, int drawingPosition){
if(drawingOrderCallback!=null)
return drawingOrderCallback.getChildDrawingOrder(childCount, drawingPosition);
return super.getChildDrawingOrder(childCount, drawingPosition);
}
public void setDrawingOrderCallback(ChildDrawingOrderCallback drawingOrderCallback){
this.drawingOrderCallback=drawingOrderCallback;
}
}

View File

@@ -1,10 +1,9 @@
package org.joinmastodon.android.ui.views;
import android.content.Context;
import android.content.res.ColorStateList;
import android.graphics.drawable.Drawable;
import android.util.AttributeSet;
import android.widget.Button;
import android.view.Gravity;
import org.joinmastodon.android.R;
import org.joinmastodon.android.ui.utils.UiUtils;
@@ -12,9 +11,7 @@ import org.joinmastodon.android.ui.utils.UiUtils;
import androidx.annotation.DrawableRes;
import me.grishka.appkit.utils.V;
public class FilterChipView extends Button{
private boolean currentlySelected;
public class FilterChipView extends CheckIconSelectableTextView{
public FilterChipView(Context context){
this(context, null);
@@ -29,31 +26,37 @@ public class FilterChipView extends Button{
setCompoundDrawablePadding(V.dp(8));
setBackgroundResource(R.drawable.bg_filter_chip);
setTextAppearance(R.style.m3_label_large);
setHeight(V.dp(48));
setGravity(Gravity.CENTER_VERTICAL);
setTextColor(getResources().getColorStateList(R.color.filter_chip_text, context.getTheme()));
setCompoundDrawableTintList(ColorStateList.valueOf(UiUtils.getThemeColor(context, R.attr.colorM3OnSurface)));
updatePadding();
}
@Override
protected void drawableStateChanged(){
super.drawableStateChanged();
if(currentlySelected==isSelected())
return;
currentlySelected=isSelected();
Drawable start=currentlySelected ? getResources().getDrawable(R.drawable.ic_baseline_check_18, getContext().getTheme()) : null;
Drawable end=getCompoundDrawablesRelative()[2];
setCompoundDrawablesRelativeWithIntrinsicBounds(start, null, end, null);
updatePadding();
}
private void updatePadding(){
int vertical=V.dp(6);
int vertical=0;
Drawable[] drawables=getCompoundDrawablesRelative();
setPaddingRelative(V.dp(drawables[0]==null ? 16 : 8), vertical, V.dp(drawables[2]==null ? 16 : 8), vertical);
}
public void setDrawableEnd(@DrawableRes int drawable){
setCompoundDrawablesRelativeWithIntrinsicBounds(0, 0, drawable, 0);
Drawable icon=getResources().getDrawable(drawable, getContext().getTheme()).mutate();
icon.setBounds(0, 0, V.dp(18), V.dp(18));
icon.setTint(UiUtils.getThemeColor(getContext(), R.attr.colorM3OnSurface));
setCompoundDrawablesRelativeWithIntrinsicBounds(getCompoundDrawablesRelative()[0], null, icon, null);
updatePadding();
}
public void setDrawableStartTinted(@DrawableRes int drawable){
Drawable icon=getResources().getDrawable(drawable, getContext().getTheme()).mutate();
icon.setBounds(0, 0, V.dp(18), V.dp(18));
icon.setTint(UiUtils.getThemeColor(getContext(), R.attr.colorM3Primary));
setCompoundDrawablesRelative(icon, null, getCompoundDrawablesRelative()[2], null);
updatePadding();
}
}

View File

@@ -0,0 +1,36 @@
package org.joinmastodon.android.ui.views;
import android.content.Context;
import android.util.AttributeSet;
import android.widget.ImageView;
public class FixedAspectRatioImageView extends ImageView{
private float aspectRatio=1;
public FixedAspectRatioImageView(Context context){
this(context, null);
}
public FixedAspectRatioImageView(Context context, AttributeSet attrs){
this(context, attrs, 0);
}
public FixedAspectRatioImageView(Context context, AttributeSet attrs, int defStyle){
super(context, attrs, defStyle);
}
@Override
protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec){
int width=MeasureSpec.getSize(widthMeasureSpec);
heightMeasureSpec=Math.round(width/aspectRatio) | MeasureSpec.EXACTLY;
super.onMeasure(widthMeasureSpec, heightMeasureSpec);
}
public float getAspectRatio(){
return aspectRatio;
}
public void setAspectRatio(float aspectRatio){
this.aspectRatio=aspectRatio;
}
}

View File

@@ -35,9 +35,9 @@ import androidx.annotation.Keep;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import me.grishka.appkit.utils.CubicBezierInterpolator;
import me.grishka.appkit.utils.V;
import me.grishka.appkit.utils.CustomViewHelper;
public class FloatingHintEditTextLayout extends FrameLayout{
public class FloatingHintEditTextLayout extends FrameLayout implements CustomViewHelper{
private EditText edit;
private TextView label;
private int labelTextSize;
@@ -60,10 +60,8 @@ public class FloatingHintEditTextLayout extends FrameLayout{
public FloatingHintEditTextLayout(Context context, AttributeSet attrs, int defStyle){
super(context, attrs, defStyle);
if(isInEditMode())
V.setApplicationContext(context);
TypedArray ta=context.obtainStyledAttributes(attrs, R.styleable.FloatingHintEditTextLayout);
labelTextSize=ta.getDimensionPixelSize(R.styleable.FloatingHintEditTextLayout_android_labelTextSize, V.dp(12));
labelTextSize=ta.getDimensionPixelSize(R.styleable.FloatingHintEditTextLayout_android_labelTextSize, dp(12));
offsetY=ta.getDimensionPixelOffset(R.styleable.FloatingHintEditTextLayout_editTextOffsetY, 0);
labelColors=ta.getColorStateList(R.styleable.FloatingHintEditTextLayout_labelTextColor);
ta.recycle();
@@ -100,14 +98,18 @@ public class FloatingHintEditTextLayout extends FrameLayout{
errorView=new LinkedTextView(getContext());
errorView.setTextAppearance(R.style.m3_body_small);
errorView.setTextColor(UiUtils.getThemeColor(getContext(), R.attr.colorM3OnSurfaceVariant));
errorView.setTextColor(UiUtils.getThemeColor(getContext(), R.attr.colorM3Error));
errorView.setLinkTextColor(UiUtils.getThemeColor(getContext(), R.attr.colorM3Primary));
errorView.setLayoutParams(new LayoutParams(ViewGroup.LayoutParams.WRAP_CONTENT, ViewGroup.LayoutParams.WRAP_CONTENT));
errorView.setPadding(V.dp(16), V.dp(4), V.dp(16), 0);
errorView.setPadding(dp(16), dp(4), dp(16), 0);
errorView.setVisibility(View.GONE);
addView(errorView);
}
public void updateHint(){
label.setText(edit.getHint());
}
private void onTextChanged(Editable text){
if(errorState){
errorView.setVisibility(View.GONE);
@@ -129,7 +131,12 @@ public class FloatingHintEditTextLayout extends FrameLayout{
edit.getViewTreeObserver().removeOnPreDrawListener(this);
float scale=edit.getLineHeight()/(float)label.getLineHeight();
float transY=edit.getHeight()/2f-edit.getLineHeight()/2f+(edit.getTop()-label.getTop())-(label.getHeight()/2f-label.getLineHeight()/2f);
float transY;
if((edit.getGravity() & Gravity.TOP)==Gravity.TOP){
transY=edit.getPaddingTop()+(edit.getTop()-label.getTop())-(label.getHeight()/2f-label.getLineHeight()/2f);
}else{
transY=edit.getHeight()/2f-edit.getLineHeight()/2f+(edit.getTop()-label.getTop())-(label.getHeight()/2f-label.getLineHeight()/2f);
}
AnimatorSet anim=new AnimatorSet();
if(hintVisible){
@@ -187,7 +194,7 @@ public class FloatingHintEditTextLayout extends FrameLayout{
public void onDrawForeground(Canvas canvas){
if(getForeground()!=null && animProgress>0){
canvas.save();
float width=(label.getWidth()+V.dp(8))*animProgress;
float width=(label.getWidth()+dp(8))*animProgress;
float centerX=label.getLeft()+label.getWidth()/2f;
tmpRect.set(centerX-width/2f, label.getTop(), centerX+width/2f, label.getBottom());
if(Build.VERSION.SDK_INT>=Build.VERSION_CODES.O)
@@ -347,7 +354,7 @@ public class FloatingHintEditTextLayout extends FrameLayout{
@Override
protected void onBoundsChange(@NonNull Rect bounds){
super.onBoundsChange(bounds);
int offset=V.dp(12);
int offset=dp(12);
wrapped.setBounds(edit.getLeft()-offset, edit.getTop()-offset, edit.getRight()+offset, edit.getBottom()+offset);
}
}

View File

@@ -24,6 +24,7 @@ public class FrameLayoutThatOnlyMeasuresFirstChild extends FrameLayout{
return;
View child0=getChildAt(0);
measureChild(child0, widthMeasureSpec, heightMeasureSpec);
super.onMeasure(child0.getMeasuredWidth() | MeasureSpec.EXACTLY, child0.getMeasuredHeight() | MeasureSpec.EXACTLY);
int vpad=getPaddingTop()+getPaddingBottom();
super.onMeasure(child0.getMeasuredWidth() | MeasureSpec.EXACTLY, (child0.getMeasuredHeight()+vpad) | MeasureSpec.EXACTLY);
}
}

View File

@@ -14,12 +14,12 @@ import org.joinmastodon.android.ui.utils.UiUtils;
import java.util.List;
import me.grishka.appkit.utils.V;
import me.grishka.appkit.utils.CustomViewHelper;
public class HashtagChartView extends View{
public class HashtagChartView extends View implements CustomViewHelper{
private Paint paint=new Paint(Paint.ANTI_ALIAS_FLAG);
private Path strokePath=new Path(), fillPath=new Path();
private CornerPathEffect pathEffect=new CornerPathEffect(V.dp(3));
private final CornerPathEffect pathEffect=new CornerPathEffect(dp(3));
private float[] relativeOffsets=new float[7];
public HashtagChartView(Context context){
@@ -32,7 +32,7 @@ public class HashtagChartView extends View{
public HashtagChartView(Context context, AttributeSet attrs, int defStyle){
super(context, attrs, defStyle);
paint.setStrokeWidth(V.dp(1.71f));
paint.setStrokeWidth(dp(1));
paint.setStrokeCap(Paint.Cap.ROUND);
paint.setStrokeJoin(Paint.Join.ROUND);
}
@@ -57,20 +57,20 @@ public class HashtagChartView extends View{
return;
strokePath.rewind();
fillPath.rewind();
float step=(getWidth()-V.dp(2))/(float)(relativeOffsets.length-1);
float maxH=getHeight()-V.dp(2);
float x=getWidth()-V.dp(1);
strokePath.moveTo(x, maxH-maxH*relativeOffsets[0]+V.dp(1));
fillPath.moveTo(getWidth(), getHeight()-V.dp(1));
fillPath.lineTo(x, maxH-maxH*relativeOffsets[0]+V.dp(1));
float step=(getWidth()-dp(2))/(float)(relativeOffsets.length-1);
float maxH=getHeight()-dp(2);
float x=getWidth()-dp(1);
strokePath.moveTo(x, maxH-maxH*relativeOffsets[0]+dp(1));
fillPath.moveTo(getWidth(), getHeight()-dp(1));
fillPath.lineTo(x, maxH-maxH*relativeOffsets[0]+dp(1));
for(int i=1;i<relativeOffsets.length;i++){
float offset=relativeOffsets[i];
x-=step;
float y=maxH-maxH*offset+V.dp(1);
float y=maxH-maxH*offset+dp(1);
strokePath.lineTo(x, y);
fillPath.lineTo(x, y);
}
fillPath.lineTo(V.dp(1), getHeight()-V.dp(1));
fillPath.lineTo(dp(1), getHeight()-dp(1));
fillPath.close();
}
@@ -83,11 +83,11 @@ public class HashtagChartView extends View{
@Override
protected void onDraw(Canvas canvas){
paint.setStyle(Paint.Style.FILL);
paint.setColor(UiUtils.getThemeColor(getContext(), R.attr.colorAccentLightest));
paint.setColor(UiUtils.getThemeColor(getContext(), R.attr.colorM3PrimaryInverse));
paint.setPathEffect(null);
canvas.drawPath(fillPath, paint);
paint.setStyle(Paint.Style.STROKE);
paint.setColor(UiUtils.getThemeColor(getContext(), android.R.attr.colorAccent));
paint.setColor(UiUtils.getThemeColor(getContext(), R.attr.colorM3Primary));
paint.setPathEffect(pathEffect);
canvas.drawPath(strokePath, paint);
}

View File

@@ -0,0 +1,36 @@
package org.joinmastodon.android.ui.views;
import android.content.Context;
import android.util.AttributeSet;
import android.view.View;
import android.view.ViewGroup;
import android.widget.HorizontalScrollView;
public class HorizontalScrollViewThatRespectsMatchParent extends HorizontalScrollView{
public HorizontalScrollViewThatRespectsMatchParent(Context context){
super(context);
}
public HorizontalScrollViewThatRespectsMatchParent(Context context, AttributeSet attrs){
super(context, attrs);
}
public HorizontalScrollViewThatRespectsMatchParent(Context context, AttributeSet attrs, int defStyleAttr){
super(context, attrs, defStyleAttr);
}
@Override
protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec){
if(getChildCount()==0)
return;
View child=getChildAt(0);
ViewGroup.LayoutParams lp=child.getLayoutParams();
if(lp.width==ViewGroup.LayoutParams.MATCH_PARENT){
int hms=getChildMeasureSpec(heightMeasureSpec, getPaddingTop()+getPaddingBottom(), lp.height);
child.measure(MeasureSpec.getSize(widthMeasureSpec) | MeasureSpec.EXACTLY, hms);
setMeasuredDimension(child.getMeasuredWidth(), child.getMeasuredHeight());
return;
}
super.onMeasure(widthMeasureSpec, heightMeasureSpec);
}
}

View File

@@ -0,0 +1,83 @@
package org.joinmastodon.android.ui.views;
import android.content.Context;
import android.graphics.Canvas;
import android.graphics.ColorFilter;
import android.graphics.drawable.Drawable;
import android.util.AttributeSet;
import android.widget.Switch;
import java.lang.reflect.Field;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import me.grishka.appkit.utils.V;
public class M3Switch extends Switch{
private boolean ignoreRequestLayout;
private DummyDrawable dummyDrawable=new DummyDrawable();
public M3Switch(Context context){
super(context);
}
public M3Switch(Context context, AttributeSet attrs){
super(context, attrs);
}
public M3Switch(Context context, AttributeSet attrs, int defStyleAttr){
super(context, attrs, defStyleAttr);
}
@Override
public void onMeasure(int widthMeasureSpec, int heightMeasureSpec){
ignoreRequestLayout=true;
Drawable prevThumbDrawable=getThumbDrawable();
setThumbDrawable(dummyDrawable);
ignoreRequestLayout=false;
super.onMeasure(widthMeasureSpec, heightMeasureSpec);
ignoreRequestLayout=true;
setThumbDrawable(prevThumbDrawable);
ignoreRequestLayout=false;
try{
Field fld=Switch.class.getDeclaredField("mThumbWidth");
fld.setAccessible(true);
fld.set(this, V.dp(32));
}catch(Exception ignore){}
}
@Override
public void requestLayout(){
if(ignoreRequestLayout)
return;
super.requestLayout();
}
private static class DummyDrawable extends Drawable{
@Override
public void draw(@NonNull Canvas canvas){
}
@Override
public void setAlpha(int alpha){
}
@Override
public void setColorFilter(@Nullable ColorFilter colorFilter){
}
@Override
public int getOpacity(){
return 0;
}
@Override
public int getIntrinsicWidth(){
return V.dp(26);
}
}
}

View File

@@ -1,8 +1,13 @@
package org.joinmastodon.android.ui.views;
import android.content.Context;
import android.content.res.TypedArray;
import android.util.AttributeSet;
import android.view.View;
import android.webkit.WebView;
import android.widget.ScrollView;
import org.joinmastodon.android.R;
import java.util.function.Supplier;
@@ -10,63 +15,102 @@ import androidx.recyclerview.widget.LinearLayoutManager;
import androidx.recyclerview.widget.RecyclerView;
public class NestedRecyclerScrollView extends CustomScrollView{
private Supplier<RecyclerView> scrollableChildSupplier;
private Supplier<View> scrollableChildSupplier;
private boolean takePriorityOverChildViews;
public NestedRecyclerScrollView(Context context){
super(context);
this(context, null);
}
public NestedRecyclerScrollView(Context context, AttributeSet attrs){
super(context, attrs);
this(context, attrs, 0);
}
public NestedRecyclerScrollView(Context context, AttributeSet attrs, int defStyleAttr){
super(context, attrs, defStyleAttr);
public NestedRecyclerScrollView(Context context, AttributeSet attrs, int defStyle){
super(context, attrs, defStyle);
TypedArray ta=context.obtainStyledAttributes(attrs, R.styleable.NestedRecyclerScrollView);
takePriorityOverChildViews=ta.getBoolean(R.styleable.NestedRecyclerScrollView_takePriorityOverChildViews, false);
ta.recycle();
}
@Override
public void onNestedPreScroll(View target, int dx, int dy, int[] consumed) {
final RecyclerView rv = (RecyclerView) target;
if ((dy < 0 && isScrolledToTop(rv)) || (dy > 0 && !isScrolledToBottom())) {
public void onNestedPreScroll(View target, int dx, int dy, int[] consumed){
if(takePriorityOverChildViews){
if((dy<0 && getScrollY()>0) || (dy>0 && canScrollVertically(1))){
scrollBy(0, dy);
consumed[1]=dy;
return;
}
}else if((dy<0 && isScrolledToTop(target)) || (dy>0 && !isScrolledToBottom())){
scrollBy(0, dy);
consumed[1] = dy;
consumed[1]=dy;
return;
}
super.onNestedPreScroll(target, dx, dy, consumed);
}
@Override
public boolean onNestedPreFling(View target, float velX, float velY) {
final RecyclerView rv = (RecyclerView) target;
if ((velY < 0 && isScrolledToTop(rv)) || (velY > 0 && !isScrolledToBottom())) {
public boolean onNestedPreFling(View target, float velX, float velY){
if(takePriorityOverChildViews){
if((velY<0 && getScrollY()>0) || (velY>0 && canScrollVertically(1))){
fling((int)velY);
return true;
}
}else if((velY<0 && isScrolledToTop(target)) || (velY>0 && !isScrolledToBottom())){
fling((int) velY);
return true;
}
return super.onNestedPreFling(target, velX, velY);
}
private boolean isScrolledToBottom() {
private boolean isScrolledToBottom(){
return !canScrollVertically(1);
}
private boolean isScrolledToTop(RecyclerView rv) {
final LinearLayoutManager lm = (LinearLayoutManager) rv.getLayoutManager();
return lm.findFirstVisibleItemPosition() == 0
&& lm.findViewByPosition(0).getTop() == rv.getPaddingTop();
private boolean isScrolledToTop(View view){
if(view instanceof RecyclerView rv){
final LinearLayoutManager lm=(LinearLayoutManager) rv.getLayoutManager();
return lm.findFirstVisibleItemPosition()==0
&& lm.findViewByPosition(0).getTop()==rv.getPaddingTop();
}
return !view.canScrollVertically(-1);
}
public void setScrollableChildSupplier(Supplier<RecyclerView> scrollableChildSupplier){
public void setScrollableChildSupplier(Supplier<View> scrollableChildSupplier){
this.scrollableChildSupplier=scrollableChildSupplier;
}
@Override
protected boolean onScrollingHitEdge(float velocity){
if(velocity>0){
RecyclerView view=scrollableChildSupplier.get();
if(view!=null){
return view.fling(0, (int)velocity);
if(velocity>0 || takePriorityOverChildViews){
View view=scrollableChildSupplier==null ? null : scrollableChildSupplier.get();
if(view instanceof RecyclerView rv){
return rv.fling(0, (int) velocity);
}else if(view instanceof ScrollView sv){
if(sv.canScrollVertically((int)velocity)){
sv.fling((int)velocity);
return true;
}
}else if(view instanceof CustomScrollView sv){
if(sv.canScrollVertically((int)velocity)){
sv.fling((int)velocity);
return true;
}
}else if(view instanceof WebView wv){
if(wv.canScrollVertically((int)velocity)){
wv.flingScroll(0, (int)velocity);
return true;
}
}
}
return false;
}
public boolean isTakePriorityOverChildViews(){
return takePriorityOverChildViews;
}
public void setTakePriorityOverChildViews(boolean takePriorityOverChildViews){
this.takePriorityOverChildViews=takePriorityOverChildViews;
}
}

View File

@@ -2,23 +2,49 @@ package org.joinmastodon.android.ui.views;
import android.content.Context;
import android.util.AttributeSet;
import android.util.Log;
import android.view.MotionEvent;
import android.view.View;
import android.view.ViewTreeObserver;
import android.view.animation.Interpolator;
import android.widget.LinearLayout;
import androidx.annotation.Nullable;
import me.grishka.appkit.utils.CubicBezierInterpolator;
import me.grishka.appkit.utils.CustomViewHelper;
import me.grishka.appkit.utils.V;
public class ReorderableLinearLayout extends LinearLayout{
public class ReorderableLinearLayout extends LinearLayout implements CustomViewHelper{
private static final String TAG="ReorderableLinearLayout";
private static final Interpolator sDragScrollInterpolator=t->t * t * t * t * t;
private static final Interpolator sDragViewScrollCapInterpolator=t->{
t -= 1.0f;
return t * t * t * t * t + 1.0f;
};
private static final long DRAG_SCROLL_ACCELERATION_LIMIT_TIME_MS = 2000;
private View draggedView;
private View bottomSibling, topSibling;
private float startY;
private float startX, startY, dX, dY, viewStartX, viewStartY;
private OnDragListener dragListener;
private boolean moveInBothDimensions;
private int edgeSize;
private View scrollableParent;
private long dragScrollStartTime;
private int cachedMaxScrollSpeed=-1;
final Runnable scrollRunnable= new Runnable() {
@Override
public void run() {
if (draggedView != null && scrollIfNecessary()) {
if (draggedView != null) { //it might be lost during scrolling
// moveIfNecessary(mSelected);
}
removeCallbacks(scrollRunnable);
postOnAnimation(this);
}
}
};
public ReorderableLinearLayout(Context context){
super(context);
@@ -30,12 +56,13 @@ public class ReorderableLinearLayout extends LinearLayout{
public ReorderableLinearLayout(Context context, @Nullable AttributeSet attrs, int defStyleAttr){
super(context, attrs, defStyleAttr);
edgeSize=dp(20);
}
public void startDragging(View child){
getParent().requestDisallowInterceptTouchEvent(true);
draggedView=child;
draggedView.animate().translationZ(V.dp(1f)).setDuration(150).setInterpolator(CubicBezierInterpolator.DEFAULT).start();
dragListener.onDragStart(draggedView);
int index=indexOfChild(child);
if(index==-1)
@@ -44,11 +71,30 @@ public class ReorderableLinearLayout extends LinearLayout{
topSibling=getChildAt(index-1);
if(index<getChildCount()-1)
bottomSibling=getChildAt(index+1);
scrollableParent=findScrollableParent(this);
viewStartX=child.getX();
viewStartY=child.getY();
}
private View findScrollableParent(View child){
if(getOrientation()==VERTICAL){
if(child.canScrollVertically(-1) || child.canScrollVertically(1))
return child;
}else{
if(child.canScrollHorizontally(-1) || child.canScrollHorizontally(1))
return child;
}
if(child.getParent() instanceof View v)
return findScrollableParent(v);
return null;
}
@Override
public boolean onInterceptTouchEvent(MotionEvent ev){
if(draggedView!=null){
startX=ev.getX();
startY=ev.getY();
return true;
}
@@ -60,34 +106,61 @@ public class ReorderableLinearLayout extends LinearLayout{
if(draggedView!=null){
if(ev.getAction()==MotionEvent.ACTION_UP || ev.getAction()==MotionEvent.ACTION_CANCEL){
endDrag();
removeCallbacks(scrollRunnable);
draggedView=null;
bottomSibling=null;
topSibling=null;
}else if(ev.getAction()==MotionEvent.ACTION_MOVE){
draggedView.setTranslationY(ev.getY()-startY);
if(topSibling!=null && draggedView.getY()<=topSibling.getY()){
moveDraggedView(-1);
}else if(bottomSibling!=null && draggedView.getY()>=bottomSibling.getY()){
moveDraggedView(1);
dX=ev.getX()-startX;
dY=ev.getY()-startY;
if(moveInBothDimensions){
draggedView.setTranslationX(dX);
draggedView.setTranslationY(dY);
}else if(getOrientation()==VERTICAL){
draggedView.setTranslationY(dY);
}else{
draggedView.setTranslationX(dX);
}
removeCallbacks(scrollRunnable);
scrollRunnable.run();
if(getOrientation()==VERTICAL){
if(topSibling!=null && draggedView.getY()<=topSibling.getY()){
moveDraggedView(-1);
}else if(bottomSibling!=null && draggedView.getY()>=bottomSibling.getY()){
moveDraggedView(1);
}
}else{
if(topSibling!=null && draggedView.getX()<=topSibling.getX()){
moveDraggedView(-1);
}else if(bottomSibling!=null && draggedView.getX()>=bottomSibling.getX()){
moveDraggedView(1);
}
}
dragListener.onDragMove(draggedView);
}
}
return super.onTouchEvent(ev);
}
private void endDrag(){
draggedView.animate().translationY(0f).translationZ(0f).setDuration(200).setInterpolator(CubicBezierInterpolator.DEFAULT).start();
dragListener.onDragEnd(draggedView);
}
private void moveDraggedView(int positionOffset){
int index=indexOfChild(draggedView);
int prevTop=draggedView.getTop();
boolean isVertical=getOrientation()==VERTICAL;
int prevOffset=isVertical ? draggedView.getTop() : draggedView.getLeft();
removeView(draggedView);
int prevIndex=index;
index+=positionOffset;
addView(draggedView, index);
final View prevSibling=positionOffset<0 ? topSibling : bottomSibling;
int prevSiblingTop=prevSibling.getTop();
int prevSiblingOffset=isVertical ? prevSibling.getTop() : prevSibling.getLeft();
if(index>0)
topSibling=getChildAt(index-1);
else
@@ -101,11 +174,20 @@ public class ReorderableLinearLayout extends LinearLayout{
@Override
public boolean onPreDraw(){
draggedView.getViewTreeObserver().removeOnPreDrawListener(this);
float offset=prevTop-draggedView.getTop();
startY-=offset;
draggedView.setTranslationY(draggedView.getTranslationY()+offset);
prevSibling.setTranslationY(prevSiblingTop-prevSibling.getTop());
prevSibling.animate().translationY(0f).setInterpolator(CubicBezierInterpolator.DEFAULT).setDuration(200).start();
float offset=prevOffset-(isVertical ? draggedView.getTop() : draggedView.getLeft());
if(isVertical){
startY-=offset;
viewStartY-=offset;
draggedView.setTranslationY(draggedView.getTranslationY()+offset);
prevSibling.setTranslationY(prevSiblingOffset-prevSibling.getTop());
prevSibling.animate().translationY(0f).setInterpolator(CubicBezierInterpolator.DEFAULT).setDuration(200).start();
}else{
startX-=offset;
viewStartX-=offset;
draggedView.setTranslationX(draggedView.getTranslationX()+offset);
prevSibling.setTranslationX(prevSiblingOffset-prevSibling.getLeft());
prevSibling.animate().translationX(0f).setInterpolator(CubicBezierInterpolator.DEFAULT).setDuration(200).start();
}
return true;
}
});
@@ -115,7 +197,105 @@ public class ReorderableLinearLayout extends LinearLayout{
this.dragListener=dragListener;
}
public boolean isMoveInBothDimensions(){
return moveInBothDimensions;
}
public void setMoveInBothDimensions(boolean moveInBothDimensions){
this.moveInBothDimensions=moveInBothDimensions;
}
boolean scrollIfNecessary(){
if(draggedView==null || scrollableParent==null){
dragScrollStartTime=Long.MIN_VALUE;
return false;
}
final long now=System.currentTimeMillis();
final long scrollDuration=dragScrollStartTime==Long.MIN_VALUE ? 0 : now-dragScrollStartTime;
int scrollX=0;
int scrollY=0;
if(getOrientation()==HORIZONTAL){
int curX=(int) (viewStartX+dX)-scrollableParent.getScrollX();
final int leftDiff=curX-getPaddingLeft();
if(dX<0 && leftDiff<0){
scrollX=leftDiff;
}else if(dX>0){
final int rightDiff=curX+draggedView.getWidth()-(scrollableParent.getWidth()-getPaddingRight());
if(rightDiff>0){
scrollX=rightDiff;
}
}
}else{
int curY=(int) (viewStartY+dY)-scrollableParent.getScrollY();
final int topDiff=curY-getPaddingTop();
if(dY<0 && topDiff<0){
scrollY=topDiff;
}else if(dY>0){
final int bottomDiff=curY+draggedView.getHeight()-(scrollableParent.getHeight()-getPaddingBottom());
if(bottomDiff>0){
scrollY=bottomDiff;
}
}
}
if(scrollX!=0){
scrollX=interpolateOutOfBoundsScroll(draggedView.getWidth(), scrollX, scrollableParent.getWidth(), scrollDuration);
}
if(scrollY!=0){
scrollY=interpolateOutOfBoundsScroll(draggedView.getHeight(), scrollY, scrollableParent.getHeight(), scrollDuration);
}
if(scrollX!=0 || scrollY!=0){
if(dragScrollStartTime==Long.MIN_VALUE){
dragScrollStartTime=now;
}
int prevX=scrollableParent.getScrollX();
int prevY=scrollableParent.getScrollY();
scrollableParent.scrollBy(scrollX, scrollY);
draggedView.setTranslationX(draggedView.getTranslationX()-(scrollableParent.getScrollX()-prevX));
draggedView.setTranslationY(draggedView.getTranslationY()-(scrollableParent.getScrollY()-prevY));
return true;
}
dragScrollStartTime=Long.MIN_VALUE;
return false;
}
public int interpolateOutOfBoundsScroll(int viewSize, int viewSizeOutOfBounds, int totalSize, long msSinceStartScroll){
final int maxScroll=getMaxDragScroll();
final int absOutOfBounds=Math.abs(viewSizeOutOfBounds);
final int direction=(int) Math.signum(viewSizeOutOfBounds);
// might be negative if other direction
float outOfBoundsRatio=Math.min(1f, 1f*absOutOfBounds/viewSize);
final int cappedScroll=(int) (direction*maxScroll*sDragViewScrollCapInterpolator.getInterpolation(outOfBoundsRatio));
final float timeRatio;
if(msSinceStartScroll>DRAG_SCROLL_ACCELERATION_LIMIT_TIME_MS){
timeRatio=1f;
}else{
timeRatio=(float) msSinceStartScroll/DRAG_SCROLL_ACCELERATION_LIMIT_TIME_MS;
}
final int value=(int) (cappedScroll*sDragScrollInterpolator.getInterpolation(timeRatio));
if(value==0){
return viewSizeOutOfBounds>0 ? 1 : -1;
}
return value;
}
private int getMaxDragScroll(){
if(cachedMaxScrollSpeed==-1){
cachedMaxScrollSpeed=getResources().getDimensionPixelSize(androidx.recyclerview.R.dimen.item_touch_helper_max_drag_scroll_per_frame);
}
return cachedMaxScrollSpeed;
}
public interface OnDragListener{
void onSwapItems(int oldIndex, int newIndex);
default void onDragStart(View view){
view.animate().translationZ(V.dp(3f)).setDuration(150).setInterpolator(CubicBezierInterpolator.DEFAULT).start();
}
default void onDragEnd(View view){
view.animate().translationY(0f).translationX(0f).translationZ(0f).setDuration(200).setInterpolator(CubicBezierInterpolator.DEFAULT).start();
}
default void onDragMove(View view){}
}
}

View File

@@ -40,7 +40,7 @@ public class TextInputFrameLayout extends FrameLayout {
editText.setHint(hint);
editText.setText(text);
FrameLayout.LayoutParams params = new FrameLayout.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.WRAP_CONTENT);
params.setMargins(V.dp(24), V.dp(4), V.dp(24), V.dp(16));
params.setMargins(V.dp(24), V.dp(4), V.dp(24), 0);
editText.setLayoutParams(params);
addView(editText);
}

View File

@@ -0,0 +1,29 @@
package org.joinmastodon.android.ui.views;
import android.content.Context;
import android.util.AttributeSet;
import android.widget.LinearLayout;
public class TopBarsScrollAwayLinearLayout extends LinearLayout{
public TopBarsScrollAwayLinearLayout(Context context){
this(context, null);
}
public TopBarsScrollAwayLinearLayout(Context context, AttributeSet attrs){
this(context, attrs, 0);
}
public TopBarsScrollAwayLinearLayout(Context context, AttributeSet attrs, int defStyle){
super(context, attrs, defStyle);
}
@Override
protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec){
super.onMeasure(widthMeasureSpec, heightMeasureSpec);
int topBarsHeight=0;
for(int i=0;i<getChildCount()-1;i++){
topBarsHeight+=getChildAt(i).getMeasuredHeight();
}
super.onMeasure(widthMeasureSpec, (MeasureSpec.getSize(heightMeasureSpec)+topBarsHeight) | MeasureSpec.EXACTLY);
}
}