From f73849dbb7477a9e25edea5b8b91c188c5ea6ad0 Mon Sep 17 00:00:00 2001 From: Grishka Date: Thu, 28 Apr 2022 23:22:55 +0300 Subject: [PATCH] Home timeline auto-refresh close #32 --- .../android/api/CacheController.java | 25 +- .../requests/timelines/GetHomeTimeline.java | 4 +- .../fragments/BaseStatusListFragment.java | 11 +- .../fragments/HomeTimelineFragment.java | 289 +++++++++++++++++- .../model/CacheablePaginatedResponse.java | 14 + .../joinmastodon/android/model/Status.java | 1 + .../ui/displayitems/GapStatusDisplayItem.java | 48 +++ .../ui/displayitems/StatusDisplayItem.java | 6 +- .../ui/drawables/SawtoothTearDrawable.java | 107 +++++++ .../main/res/drawable/bg_button_new_posts.xml | 10 + .../src/main/res/drawable/bg_timeline_gap.xml | 7 + .../drawable/ic_fluent_arrow_up_16_filled.xml | 3 + .../src/main/res/layout/display_item_gap.xml | 24 ++ mastodon/src/main/res/values/strings.xml | 2 + mastodon/src/main/res/values/styles.xml | 2 +- 15 files changed, 531 insertions(+), 22 deletions(-) create mode 100644 mastodon/src/main/java/org/joinmastodon/android/model/CacheablePaginatedResponse.java create mode 100644 mastodon/src/main/java/org/joinmastodon/android/ui/displayitems/GapStatusDisplayItem.java create mode 100644 mastodon/src/main/java/org/joinmastodon/android/ui/drawables/SawtoothTearDrawable.java create mode 100644 mastodon/src/main/res/drawable/bg_button_new_posts.xml create mode 100644 mastodon/src/main/res/drawable/bg_timeline_gap.xml create mode 100644 mastodon/src/main/res/drawable/ic_fluent_arrow_up_16_filled.xml create mode 100644 mastodon/src/main/res/layout/display_item_gap.xml diff --git a/mastodon/src/main/java/org/joinmastodon/android/api/CacheController.java b/mastodon/src/main/java/org/joinmastodon/android/api/CacheController.java index 7d5c7c45..f1dfcea3 100644 --- a/mastodon/src/main/java/org/joinmastodon/android/api/CacheController.java +++ b/mastodon/src/main/java/org/joinmastodon/android/api/CacheController.java @@ -14,6 +14,7 @@ import org.joinmastodon.android.MastodonApp; import org.joinmastodon.android.api.requests.notifications.GetNotifications; import org.joinmastodon.android.api.requests.timelines.GetHomeTimeline; import org.joinmastodon.android.api.session.AccountSessionManager; +import org.joinmastodon.android.model.CacheablePaginatedResponse; import org.joinmastodon.android.model.Filter; import org.joinmastodon.android.model.Notification; import org.joinmastodon.android.model.PaginatedResponse; @@ -41,6 +42,8 @@ public class CacheController{ private DatabaseHelper db; private final Runnable databaseCloseRunnable=this::closeDatabase; + private static final int POST_FLAG_GAP_AFTER=1; + static{ databaseThread.start(); } @@ -49,14 +52,14 @@ public class CacheController{ this.accountID=accountID; } - public void getHomeTimeline(String maxID, int count, boolean forceReload, Callback>> callback){ + public void getHomeTimeline(String maxID, int count, boolean forceReload, Callback>> callback){ cancelDelayedClose(); databaseThread.postRunnable(()->{ try{ List filters=AccountSessionManager.getInstance().getAccount(accountID).wordFilters.stream().filter(f->f.context.contains(Filter.FilterContext.HOME)).collect(Collectors.toList()); if(!forceReload){ SQLiteDatabase db=getOrOpenDatabase(); - try(Cursor cursor=db.query("home_timeline", new String[]{"json"}, maxID==null ? null : "`id` result=new ArrayList<>(); cursor.moveToFirst(); @@ -65,6 +68,8 @@ public class CacheController{ do{ Status status=MastodonAPIController.gson.fromJson(cursor.getString(0), Status.class); status.postprocess(); + int flags=cursor.getInt(1); + status.hasGapAfter=((flags & POST_FLAG_GAP_AFTER)!=0); newMaxID=status.id; for(Filter filter:filters){ if(filter.matches(status.getContentStatus().content)) @@ -73,25 +78,25 @@ public class CacheController{ result.add(status); }while(cursor.moveToNext()); String _newMaxID=newMaxID; - uiHandler.post(()->callback.onSuccess(new PaginatedResponse<>(result, _newMaxID))); + uiHandler.post(()->callback.onSuccess(new CacheablePaginatedResponse<>(result, _newMaxID, true))); return; } }catch(IOException x){ Log.w(TAG, "getHomeTimeline: corrupted status object in database", x); } } - new GetHomeTimeline(maxID, null, count) + new GetHomeTimeline(maxID, null, count, null) .setCallback(new Callback<>(){ @Override public void onSuccess(List result){ - callback.onSuccess(new PaginatedResponse<>(result.stream().filter(post->{ + callback.onSuccess(new CacheablePaginatedResponse<>(result.stream().filter(post->{ for(Filter filter:filters){ if(filter.matches(post.getContentStatus().content)){ return false; } } return true; - }).collect(Collectors.toList()), result.isEmpty() ? null : result.get(result.size()-1).id)); + }).collect(Collectors.toList()), result.isEmpty() ? null : result.get(result.size()-1).id, false)); putHomeTimeline(result, maxID==null); } @@ -110,14 +115,18 @@ public class CacheController{ }, 0); } - private void putHomeTimeline(List posts, boolean clear){ + public void putHomeTimeline(List posts, boolean clear){ runOnDbThread((db)->{ if(clear) db.delete("home_timeline", null, null); - ContentValues values=new ContentValues(2); + ContentValues values=new ContentValues(3); for(Status s:posts){ values.put("id", s.id); values.put("json", MastodonAPIController.gson.toJson(s)); + int flags=0; + if(s.hasGapAfter) + flags|=POST_FLAG_GAP_AFTER; + values.put("flags", flags); db.insertWithOnConflict("home_timeline", null, values, SQLiteDatabase.CONFLICT_REPLACE); } }); diff --git a/mastodon/src/main/java/org/joinmastodon/android/api/requests/timelines/GetHomeTimeline.java b/mastodon/src/main/java/org/joinmastodon/android/api/requests/timelines/GetHomeTimeline.java index c77edb5d..a84978d2 100644 --- a/mastodon/src/main/java/org/joinmastodon/android/api/requests/timelines/GetHomeTimeline.java +++ b/mastodon/src/main/java/org/joinmastodon/android/api/requests/timelines/GetHomeTimeline.java @@ -8,12 +8,14 @@ import org.joinmastodon.android.model.Status; import java.util.List; public class GetHomeTimeline extends MastodonAPIRequest>{ - public GetHomeTimeline(String maxID, String minID, int limit){ + public GetHomeTimeline(String maxID, String minID, int limit, String sinceID){ super(HttpMethod.GET, "/timelines/home", new TypeToken<>(){}); if(maxID!=null) addQueryParameter("max_id", maxID); if(minID!=null) addQueryParameter("min_id", minID); + if(sinceID!=null) + addQueryParameter("since_id", sinceID); if(limit>0) addQueryParameter("limit", ""+limit); } diff --git a/mastodon/src/main/java/org/joinmastodon/android/fragments/BaseStatusListFragment.java b/mastodon/src/main/java/org/joinmastodon/android/fragments/BaseStatusListFragment.java index 1a33c06d..b9b5358f 100644 --- a/mastodon/src/main/java/org/joinmastodon/android/fragments/BaseStatusListFragment.java +++ b/mastodon/src/main/java/org/joinmastodon/android/fragments/BaseStatusListFragment.java @@ -31,6 +31,7 @@ import org.joinmastodon.android.model.Status; import org.joinmastodon.android.ui.BetterItemAnimator; import org.joinmastodon.android.ui.PhotoLayoutHelper; import org.joinmastodon.android.ui.TileGridLayoutManager; +import org.joinmastodon.android.ui.displayitems.GapStatusDisplayItem; import org.joinmastodon.android.ui.displayitems.HeaderStatusDisplayItem; import org.joinmastodon.android.ui.displayitems.ImageStatusDisplayItem; import org.joinmastodon.android.ui.displayitems.PollFooterStatusDisplayItem; @@ -281,6 +282,10 @@ public abstract class BaseStatusListFragment exten list.getDecoratedBoundsWithMargins(view, outRect); RecyclerView.ViewHolder holder=list.getChildViewHolder(view); if(holder instanceof StatusDisplayItem.Holder){ + if(((StatusDisplayItem.Holder) holder).getItem().getType()==StatusDisplayItem.Type.GAP){ + outRect.setEmpty(); + return; + } String id=((StatusDisplayItem.Holder) holder).getItemID(); for(int i=0;i exten } } + public void onGapClick(GapStatusDisplayItem.Holder item){} + public String getAccountID(){ return accountID; } @@ -653,8 +660,8 @@ public abstract class BaseStatusListFragment exten View bottomSibling=parent.getChildAt(i+1); RecyclerView.ViewHolder holder=parent.getChildViewHolder(child); RecyclerView.ViewHolder siblingHolder=parent.getChildViewHolder(bottomSibling); - if(holder instanceof StatusDisplayItem.Holder && siblingHolder instanceof StatusDisplayItem.Holder - && !((StatusDisplayItem.Holder) holder).getItemID().equals(((StatusDisplayItem.Holder) siblingHolder).getItemID())){ + if(holder instanceof StatusDisplayItem.Holder ih && siblingHolder instanceof StatusDisplayItem.Holder sh + && !ih.getItemID().equals(sh.getItemID()) && ih.getItem().getType()!=StatusDisplayItem.Type.GAP){ drawDivider(child, bottomSibling, holder, siblingHolder, parent, c, dividerPaint); } } diff --git a/mastodon/src/main/java/org/joinmastodon/android/fragments/HomeTimelineFragment.java b/mastodon/src/main/java/org/joinmastodon/android/fragments/HomeTimelineFragment.java index 48a7c5ec..db7033d2 100644 --- a/mastodon/src/main/java/org/joinmastodon/android/fragments/HomeTimelineFragment.java +++ b/mastodon/src/main/java/org/joinmastodon/android/fragments/HomeTimelineFragment.java @@ -1,14 +1,22 @@ package org.joinmastodon.android.fragments; +import android.animation.Animator; +import android.animation.AnimatorListenerAdapter; +import android.animation.AnimatorSet; +import android.animation.ObjectAnimator; import android.app.Activity; import android.content.res.ColorStateList; import android.content.res.Configuration; +import android.os.Build; import android.os.Bundle; import android.view.Gravity; import android.view.Menu; import android.view.MenuInflater; import android.view.MenuItem; import android.view.View; +import android.view.ViewGroup; +import android.widget.Button; +import android.widget.FrameLayout; import android.widget.ImageButton; import android.widget.ImageView; import android.widget.Toolbar; @@ -16,20 +24,35 @@ import android.widget.Toolbar; import com.squareup.otto.Subscribe; import org.joinmastodon.android.R; +import org.joinmastodon.android.api.requests.timelines.GetHomeTimeline; import org.joinmastodon.android.api.session.AccountSessionManager; import org.joinmastodon.android.events.StatusCreatedEvent; -import org.joinmastodon.android.model.PaginatedResponse; +import org.joinmastodon.android.model.CacheablePaginatedResponse; import org.joinmastodon.android.model.Status; +import org.joinmastodon.android.ui.displayitems.GapStatusDisplayItem; +import org.joinmastodon.android.ui.displayitems.StatusDisplayItem; import org.joinmastodon.android.ui.utils.UiUtils; import java.util.Collections; +import java.util.HashSet; import java.util.List; +import java.util.Set; +import androidx.annotation.NonNull; +import androidx.recyclerview.widget.RecyclerView; import me.grishka.appkit.Nav; +import me.grishka.appkit.api.Callback; +import me.grishka.appkit.api.ErrorResponse; import me.grishka.appkit.api.SimpleCallback; +import me.grishka.appkit.utils.CubicBezierInterpolator; +import me.grishka.appkit.utils.V; public class HomeTimelineFragment extends StatusListFragment{ private ImageButton fab; + private ImageView toolbarLogo; + private Button toolbarShowNewPostsBtn; + private boolean newPostsBtnShown; + private AnimatorSet currentNewPostsAnim; private String maxID; @@ -50,11 +73,13 @@ public class HomeTimelineFragment extends StatusListFragment{ .getAccount(accountID).getCacheController() .getHomeTimeline(offset>0 ? maxID : null, count, refreshing, new SimpleCallback<>(this){ @Override - public void onSuccess(PaginatedResponse> result){ + public void onSuccess(CacheablePaginatedResponse> result){ if(getActivity()==null) return; onDataLoaded(result.items, !result.items.isEmpty()); maxID=result.maxID; + if(result.isFromCache()) + loadNewPosts(); } }); } @@ -65,6 +90,14 @@ public class HomeTimelineFragment extends StatusListFragment{ fab=view.findViewById(R.id.fab); fab.setOnClickListener(this::onFabClick); updateToolbarLogo(); + list.addOnScrollListener(new RecyclerView.OnScrollListener(){ + @Override + public void onScrolled(@NonNull RecyclerView recyclerView, int dx, int dy){ + if(newPostsBtnShown && list.getChildAdapterPosition(list.getChildAt(0))<=getMainAdapterOffset()){ + hideNewPostsButton(); + } + } + }); } @Override @@ -89,8 +122,13 @@ public class HomeTimelineFragment extends StatusListFragment{ @Override protected void onShown(){ super.onShown(); - if(!getArguments().getBoolean("noAutoLoad") && !loaded && !dataLoading) - loadData(); + if(!getArguments().getBoolean("noAutoLoad")){ + if(!loaded && !dataLoading){ + loadData(); + }else if(!dataLoading){ + loadNewPosts(); + } + } } @Subscribe @@ -104,12 +142,245 @@ public class HomeTimelineFragment extends StatusListFragment{ Nav.go(getActivity(), ComposeFragment.class, args); } + private void loadNewPosts(){ + dataLoading=true; + // The idea here is that we request the timeline such that if there are fewer than `limit` posts, + // we'll get the currently topmost post as last in the response. This way we know there's no gap + // between the existing and newly loaded parts of the timeline. + String sinceID=data.size()>1 ? data.get(1).id : "1"; + currentRequest=new GetHomeTimeline(null, null, 20, sinceID) + .setCallback(new Callback<>(){ + @Override + public void onSuccess(List result){ + currentRequest=null; + dataLoading=false; + if(result.isEmpty() || getActivity()==null) + return; + Status last=result.get(result.size()-1); + List toAdd; + if(last.id.equals(data.get(0).id)){ // This part intersects with the existing one + toAdd=result.subList(0, result.size()-1); // Remove the already known last post + }else{ + result.get(result.size()-1).hasGapAfter=true; + toAdd=result; + } + if(!toAdd.isEmpty()){ + prependItems(toAdd, true); + showNewPostsButton(); + AccountSessionManager.getInstance().getAccount(accountID).getCacheController().putHomeTimeline(toAdd, false); + } + } + + @Override + public void onError(ErrorResponse error){ + currentRequest=null; + dataLoading=false; + } + }) + .exec(accountID); + } + + @Override + public void onGapClick(GapStatusDisplayItem.Holder item){ + if(dataLoading) + return; + item.getItem().loading=true; + V.setVisibilityAnimated(item.progress, View.VISIBLE); + V.setVisibilityAnimated(item.text, View.GONE); + GapStatusDisplayItem gap=item.getItem(); + dataLoading=true; + currentRequest=new GetHomeTimeline(item.getItemID(), null, 20, null) + .setCallback(new Callback<>(){ + @Override + public void onSuccess(List result){ + currentRequest=null; + dataLoading=false; + if(getActivity()==null) + return; + int gapPos=displayItems.indexOf(gap); + if(gapPos==-1) + return; + if(result.isEmpty()){ + displayItems.remove(gapPos); + adapter.notifyItemRemoved(getMainAdapterOffset()+gapPos); + Status gapStatus=getStatusByID(gap.parentID); + if(gapStatus!=null){ + gapStatus.hasGapAfter=false; + AccountSessionManager.getInstance().getAccount(accountID).getCacheController().putHomeTimeline(Collections.singletonList(gapStatus), false); + } + }else{ + Set idsBelowGap=new HashSet<>(); + boolean belowGap=false; + int gapPostIndex=0; + for(Status s:data){ + if(belowGap){ + idsBelowGap.add(s.id); + }else if(s.id.equals(gap.parentID)){ + belowGap=true; + s.hasGapAfter=false; + AccountSessionManager.getInstance().getAccount(accountID).getCacheController().putHomeTimeline(Collections.singletonList(s), false); + }else{ + gapPostIndex++; + } + } + int endIndex=0; + for(Status s:result){ + endIndex++; + if(idsBelowGap.contains(s.id)) + break; + } + if(endIndex==result.size()){ + result.get(result.size()-1).hasGapAfter=true; + }else{ + result=result.subList(0, endIndex); + } + List targetList=displayItems.subList(gapPos, gapPos+1); + targetList.clear(); + List insertedPosts=data.subList(gapPostIndex+1, gapPostIndex+1); + for(Status s:result){ + if(idsBelowGap.contains(s.id)) + break; + targetList.addAll(buildDisplayItems(s)); + insertedPosts.add(s); + } + if(targetList.isEmpty()){ + // oops. We didn't add new posts, but at least we know there are none. + adapter.notifyItemRemoved(getMainAdapterOffset()+gapPos); + }else{ + adapter.notifyItemChanged(getMainAdapterOffset()+gapPos); + adapter.notifyItemRangeInserted(getMainAdapterOffset()+gapPos+1, targetList.size()-1); + } + AccountSessionManager.getInstance().getAccount(accountID).getCacheController().putHomeTimeline(insertedPosts, false); + } + } + + @Override + public void onError(ErrorResponse error){ + currentRequest=null; + dataLoading=false; + gap.loading=false; + Activity a=getActivity(); + if(a!=null){ + error.showToast(a); + int gapPos=displayItems.indexOf(gap); + if(gapPos>=0) + adapter.notifyItemChanged(gapPos); + } + } + }) + .exec(accountID); + + } + + @Override + public void onRefresh(){ + if(currentRequest!=null){ + currentRequest.cancel(); + currentRequest=null; + dataLoading=false; + } + super.onRefresh(); + } + private void updateToolbarLogo(){ - ImageView logo=new ImageView(getActivity()); - logo.setScaleType(ImageView.ScaleType.CENTER); - logo.setImageResource(R.drawable.logo); - logo.setImageTintList(ColorStateList.valueOf(UiUtils.getThemeColor(getActivity(), android.R.attr.textColorPrimary))); + toolbarLogo=new ImageView(getActivity()); + toolbarLogo.setScaleType(ImageView.ScaleType.CENTER); + toolbarLogo.setImageResource(R.drawable.logo); + toolbarLogo.setImageTintList(ColorStateList.valueOf(UiUtils.getThemeColor(getActivity(), android.R.attr.textColorPrimary))); + + toolbarShowNewPostsBtn=new Button(getActivity()); + toolbarShowNewPostsBtn.setTextAppearance(R.style.m3_title_medium); + toolbarShowNewPostsBtn.setTextColor(0xffffffff); + toolbarShowNewPostsBtn.setStateListAnimator(null); + toolbarShowNewPostsBtn.setBackgroundResource(R.drawable.bg_button_new_posts); + toolbarShowNewPostsBtn.setText(R.string.see_new_posts); + toolbarShowNewPostsBtn.setCompoundDrawablesRelativeWithIntrinsicBounds(R.drawable.ic_fluent_arrow_up_16_filled, 0, 0, 0); + toolbarShowNewPostsBtn.setCompoundDrawableTintList(toolbarShowNewPostsBtn.getTextColors()); + toolbarShowNewPostsBtn.setCompoundDrawablePadding(V.dp(8)); + if(Build.VERSION.SDK_INT extends PaginatedResponse{ + private final boolean fromCache; + + public CacheablePaginatedResponse(T items, String maxID, boolean fromCache){ + super(items, maxID); + this.fromCache=fromCache; + } + + public boolean isFromCache(){ + return fromCache; + } +} diff --git a/mastodon/src/main/java/org/joinmastodon/android/model/Status.java b/mastodon/src/main/java/org/joinmastodon/android/model/Status.java index 14c74ea0..aa6459df 100644 --- a/mastodon/src/main/java/org/joinmastodon/android/model/Status.java +++ b/mastodon/src/main/java/org/joinmastodon/android/model/Status.java @@ -54,6 +54,7 @@ public class Status extends BaseModel implements DisplayItemsParent{ public boolean pinned; public transient boolean spoilerRevealed; + public transient boolean hasGapAfter; @Override public void postprocess() throws ObjectValidationException{ diff --git a/mastodon/src/main/java/org/joinmastodon/android/ui/displayitems/GapStatusDisplayItem.java b/mastodon/src/main/java/org/joinmastodon/android/ui/displayitems/GapStatusDisplayItem.java new file mode 100644 index 00000000..7c325f65 --- /dev/null +++ b/mastodon/src/main/java/org/joinmastodon/android/ui/displayitems/GapStatusDisplayItem.java @@ -0,0 +1,48 @@ +package org.joinmastodon.android.ui.displayitems; + +import android.content.Context; +import android.view.View; +import android.view.ViewGroup; +import android.widget.ProgressBar; +import android.widget.TextView; + +import org.joinmastodon.android.R; +import org.joinmastodon.android.fragments.BaseStatusListFragment; +import org.joinmastodon.android.ui.drawables.SawtoothTearDrawable; + +// Mind the gap! +public class GapStatusDisplayItem extends StatusDisplayItem{ + public boolean loading; + + public GapStatusDisplayItem(String parentID, BaseStatusListFragment parentFragment){ + super(parentID, parentFragment); + } + + @Override + public Type getType(){ + return Type.GAP; + } + + public static class Holder extends StatusDisplayItem.Holder{ + public final ProgressBar progress; + public final TextView text; + + public Holder(Context context, ViewGroup parent){ + super(context, R.layout.display_item_gap, parent); + progress=findViewById(R.id.progress); + text=findViewById(R.id.text); + itemView.setForeground(new SawtoothTearDrawable(context)); + } + + @Override + public void onBind(GapStatusDisplayItem item){ + text.setVisibility(item.loading ? View.GONE : View.VISIBLE); + progress.setVisibility(item.loading ? View.VISIBLE : View.GONE); + } + + @Override + public void onClick(){ + item.parentFragment.onGapClick(this); + } + } +} diff --git a/mastodon/src/main/java/org/joinmastodon/android/ui/displayitems/StatusDisplayItem.java b/mastodon/src/main/java/org/joinmastodon/android/ui/displayitems/StatusDisplayItem.java index b91fd4f0..52d58583 100644 --- a/mastodon/src/main/java/org/joinmastodon/android/ui/displayitems/StatusDisplayItem.java +++ b/mastodon/src/main/java/org/joinmastodon/android/ui/displayitems/StatusDisplayItem.java @@ -63,6 +63,7 @@ public abstract class StatusDisplayItem{ case ACCOUNT_CARD -> new AccountCardStatusDisplayItem.Holder(activity, parent); case ACCOUNT -> new AccountStatusDisplayItem.Holder(activity, parent); case HASHTAG -> new HashtagStatusDisplayItem.Holder(activity, parent); + case GAP -> new GapStatusDisplayItem.Holder(activity, parent); }; } @@ -112,6 +113,8 @@ public abstract class StatusDisplayItem{ } if(addFooter){ items.add(new FooterStatusDisplayItem(parentID, fragment, statusForContent, accountID)); + if(status.hasGapAfter) + items.add(new GapStatusDisplayItem(parentID, fragment)); } int i=1; for(StatusDisplayItem item:items){ @@ -142,7 +145,8 @@ public abstract class StatusDisplayItem{ FOOTER, ACCOUNT_CARD, ACCOUNT, - HASHTAG + HASHTAG, + GAP } public static abstract class Holder extends BindableViewHolder implements UsableRecyclerView.DisableableClickable{ diff --git a/mastodon/src/main/java/org/joinmastodon/android/ui/drawables/SawtoothTearDrawable.java b/mastodon/src/main/java/org/joinmastodon/android/ui/drawables/SawtoothTearDrawable.java new file mode 100644 index 00000000..008c8feb --- /dev/null +++ b/mastodon/src/main/java/org/joinmastodon/android/ui/drawables/SawtoothTearDrawable.java @@ -0,0 +1,107 @@ +package org.joinmastodon.android.ui.drawables; + +import android.content.Context; +import android.graphics.Bitmap; +import android.graphics.BitmapShader; +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.Shader; +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 SawtoothTearDrawable extends Drawable{ + private final Paint topPaint, bottomPaint; + + private static final int TOP_SAWTOOTH_HEIGHT=5; + private static final int BOTTOM_SAWTOOTH_HEIGHT=3; + private static final int STROKE_WIDTH=2; + private static final int SAWTOOTH_PERIOD=14; + + public SawtoothTearDrawable(Context context){ + topPaint=makeShaderPaint(makeSawtoothTexture(context, TOP_SAWTOOTH_HEIGHT, SAWTOOTH_PERIOD, false, STROKE_WIDTH)); + bottomPaint=makeShaderPaint(makeSawtoothTexture(context, BOTTOM_SAWTOOTH_HEIGHT, SAWTOOTH_PERIOD, true, STROKE_WIDTH)); + Matrix matrix=new Matrix(); + //noinspection IntegerDivisionInFloatingPointContext + matrix.setTranslate(V.dp(SAWTOOTH_PERIOD/2), 0); + bottomPaint.getShader().setLocalMatrix(matrix); + } + + private Bitmap makeSawtoothTexture(Context context, int height, int period, boolean fillBottom, int strokeWidth){ + int actualStrokeWidth=V.dp(strokeWidth); + int actualPeriod=V.dp(period); + int actualHeight=V.dp(height); + Bitmap bitmap=Bitmap.createBitmap(actualPeriod, actualHeight+actualStrokeWidth*2, Bitmap.Config.ARGB_8888); + Canvas c=new Canvas(bitmap); + Path path=new Path(); + //noinspection SuspiciousNameCombination + path.moveTo(-actualPeriod/2f, actualStrokeWidth); + path.lineTo(0, actualHeight+actualStrokeWidth); + //noinspection SuspiciousNameCombination + path.lineTo(actualPeriod/2f, actualStrokeWidth); + path.lineTo(actualPeriod, actualHeight+actualStrokeWidth); + //noinspection SuspiciousNameCombination + path.lineTo(actualPeriod*1.5f, actualStrokeWidth); + if(fillBottom){ + path.lineTo(actualPeriod*1.5f, actualHeight*20); + path.lineTo(-actualPeriod/2f, actualHeight*20); + }else{ + path.lineTo(actualPeriod*1.5f, -actualHeight); + path.lineTo(-actualPeriod/2f, -actualHeight); + } + path.close(); + Paint paint=new Paint(Paint.ANTI_ALIAS_FLAG); + paint.setColor(UiUtils.getThemeColor(context, R.attr.colorWindowBackground)); + c.drawPath(path, paint); + paint.setColor(UiUtils.getThemeColor(context, R.attr.colorPollVoted)); + paint.setStrokeWidth(actualStrokeWidth); + paint.setStyle(Paint.Style.STROKE); + c.drawPath(path, paint); + return bitmap; + } + + private Paint makeShaderPaint(Bitmap bitmap){ + BitmapShader shader=new BitmapShader(bitmap, Shader.TileMode.REPEAT, Shader.TileMode.CLAMP); + Paint paint=new Paint(); + paint.setShader(shader); + return paint; + } + + @Override + public void draw(@NonNull Canvas canvas){ + int strokeWidth=V.dp(STROKE_WIDTH); + Rect bounds=getBounds(); + canvas.save(); + canvas.translate(bounds.left, bounds.top); + canvas.drawRect(0, 0, bounds.width(), V.dp(TOP_SAWTOOTH_HEIGHT)+strokeWidth*2, topPaint); + int bottomHeight=V.dp(BOTTOM_SAWTOOTH_HEIGHT)+strokeWidth*2; + canvas.translate(0, bounds.height()-bottomHeight); + canvas.drawRect(0, 0, bounds.width(), bottomHeight, bottomPaint); + canvas.restore(); + } + + @Override + public void setAlpha(int alpha){ + + } + + @Override + public void setColorFilter(@Nullable ColorFilter colorFilter){ + + } + + @Override + public int getOpacity(){ + return PixelFormat.TRANSLUCENT; + } +} diff --git a/mastodon/src/main/res/drawable/bg_button_new_posts.xml b/mastodon/src/main/res/drawable/bg_button_new_posts.xml new file mode 100644 index 00000000..2178510e --- /dev/null +++ b/mastodon/src/main/res/drawable/bg_button_new_posts.xml @@ -0,0 +1,10 @@ + + + + + + + + + + \ No newline at end of file diff --git a/mastodon/src/main/res/drawable/bg_timeline_gap.xml b/mastodon/src/main/res/drawable/bg_timeline_gap.xml new file mode 100644 index 00000000..b7be9aba --- /dev/null +++ b/mastodon/src/main/res/drawable/bg_timeline_gap.xml @@ -0,0 +1,7 @@ + + + + + + + \ No newline at end of file diff --git a/mastodon/src/main/res/drawable/ic_fluent_arrow_up_16_filled.xml b/mastodon/src/main/res/drawable/ic_fluent_arrow_up_16_filled.xml new file mode 100644 index 00000000..3ed3c917 --- /dev/null +++ b/mastodon/src/main/res/drawable/ic_fluent_arrow_up_16_filled.xml @@ -0,0 +1,3 @@ + + + diff --git a/mastodon/src/main/res/layout/display_item_gap.xml b/mastodon/src/main/res/layout/display_item_gap.xml new file mode 100644 index 00000000..cbc06a8b --- /dev/null +++ b/mastodon/src/main/res/layout/display_item_gap.xml @@ -0,0 +1,24 @@ + + + + + + + + \ No newline at end of file diff --git a/mastodon/src/main/res/values/strings.xml b/mastodon/src/main/res/values/strings.xml index 526632fc..387b2010 100644 --- a/mastodon/src/main/res/values/strings.xml +++ b/mastodon/src/main/res/values/strings.xml @@ -312,4 +312,6 @@ These are the news stories being shared the most in your corner of Mastodon. These are the most recent posts by the people who use the same Mastodon server as you. Dismiss + See new posts + Load missing posts \ No newline at end of file diff --git a/mastodon/src/main/res/values/styles.xml b/mastodon/src/main/res/values/styles.xml index 0ad3e918..c4b67770 100644 --- a/mastodon/src/main/res/values/styles.xml +++ b/mastodon/src/main/res/values/styles.xml @@ -66,7 +66,7 @@ @color/gray_800 #E9EDF2 @color/gray_700 - @color/gray_700 + @color/gray_900 @color/gray_25 @color/gray_800 @color/gray_800