Pinnable timelines (#338)

* implement draggable list

* implement pinning timelines

* fix TimelineDefinition equals not working

* implement removing timelines

* implement pinned lists/hashtags

* per-account pinned timelines

* implement pin button

* fix issues with pinning

* improve pin button

* improve pinning timelines

* implement custom icons

* fix home switcher menu

* make hashtags pinnable

* edit timelines in options menu
This commit is contained in:
sk22 2023-01-21 02:17:47 +01:00 committed by GitHub
parent 8e507e7970
commit 88851a085e
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
22 changed files with 943 additions and 135 deletions

View File

@ -8,6 +8,8 @@ import android.content.SharedPreferences;
import com.google.gson.JsonSyntaxException;
import com.google.gson.reflect.TypeToken;
import org.joinmastodon.android.model.TimelineDefinition;
import java.lang.reflect.Type;
import java.util.HashMap;
import java.util.List;
@ -20,7 +22,6 @@ public class GlobalUserPreferences{
public static boolean showReplies;
public static boolean showBoosts;
public static boolean loadNewPosts;
public static boolean showFederatedTimeline;
public static boolean showInteractionCounts;
public static boolean alwaysExpandContentWarnings;
public static boolean disableMarquee;
@ -37,13 +38,16 @@ public class GlobalUserPreferences{
public static ColorPreference color;
private final static Type recentLanguagesType = new TypeToken<Map<String, List<String>>>() {}.getType();
private final static Type pinnedTimelinesType = new TypeToken<Map<String, List<TimelineDefinition>>>() {}.getType();
public static Map<String, List<String>> recentLanguages;
public static Map<String, List<TimelineDefinition>> pinnedTimelines;
private static SharedPreferences getPrefs(){
return MastodonApp.context.getSharedPreferences("global", Context.MODE_PRIVATE);
}
private static <T> T fromJson(String json, Type type, T orElse) {
if (json == null) return orElse;
try { return gson.fromJson(json, type); }
catch (JsonSyntaxException ignored) { return orElse; }
}
@ -56,7 +60,6 @@ public class GlobalUserPreferences{
showReplies=prefs.getBoolean("showReplies", true);
showBoosts=prefs.getBoolean("showBoosts", true);
loadNewPosts=prefs.getBoolean("loadNewPosts", true);
showFederatedTimeline=prefs.getBoolean("showFederatedTimeline", !BuildConfig.BUILD_TYPE.equals("playRelease"));
showInteractionCounts=prefs.getBoolean("showInteractionCounts", false);
alwaysExpandContentWarnings=prefs.getBoolean("alwaysExpandContentWarnings", false);
disableMarquee=prefs.getBoolean("disableMarquee", false);
@ -70,7 +73,8 @@ public class GlobalUserPreferences{
disableAltTextReminder=prefs.getBoolean("disableAltTextReminder", false);
publishButtonText=prefs.getString("publishButtonText", "");
theme=ThemePreference.values()[prefs.getInt("theme", 0)];
recentLanguages=fromJson(prefs.getString("recentLanguages", "{}"), recentLanguagesType, new HashMap<>());
recentLanguages=fromJson(prefs.getString("recentLanguages", null), recentLanguagesType, new HashMap<>());
pinnedTimelines=fromJson(prefs.getString("pinnedTimelines", null), pinnedTimelinesType, new HashMap<>());
try {
color=ColorPreference.valueOf(prefs.getString("color", ColorPreference.PINK.name()));
@ -87,7 +91,6 @@ public class GlobalUserPreferences{
.putBoolean("showReplies", showReplies)
.putBoolean("showBoosts", showBoosts)
.putBoolean("loadNewPosts", loadNewPosts)
.putBoolean("showFederatedTimeline", showFederatedTimeline)
.putBoolean("trueBlackTheme", trueBlackTheme)
.putBoolean("showInteractionCounts", showInteractionCounts)
.putBoolean("alwaysExpandContentWarnings", alwaysExpandContentWarnings)
@ -103,6 +106,7 @@ public class GlobalUserPreferences{
.putInt("theme", theme.ordinal())
.putString("color", color.name())
.putString("recentLanguages", gson.toJson(recentLanguages))
.putString("pinnedTimelines", gson.toJson(pinnedTimelines))
.apply();
}

View File

@ -0,0 +1,10 @@
package org.joinmastodon.android.api.requests.lists;
import org.joinmastodon.android.api.MastodonAPIRequest;
import org.joinmastodon.android.model.ListTimeline;
public class GetList extends MastodonAPIRequest<ListTimeline> {
public GetList(String id) {
super(HttpMethod.GET, "/lists/" + id, ListTimeline.class);
}
}

View File

@ -0,0 +1,351 @@
package org.joinmastodon.android.fragments;
import static android.view.Menu.NONE;
import static org.joinmastodon.android.ui.utils.UiUtils.makeBackItem;
import android.annotation.SuppressLint;
import android.content.Context;
import android.os.Bundle;
import android.view.Menu;
import android.view.MenuInflater;
import android.view.MenuItem;
import android.view.MotionEvent;
import android.view.SubMenu;
import android.view.View;
import android.view.ViewGroup;
import android.widget.EditText;
import android.widget.ImageButton;
import android.widget.ImageView;
import android.widget.LinearLayout;
import android.widget.PopupMenu;
import android.widget.TextView;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import androidx.recyclerview.widget.ItemTouchHelper;
import androidx.recyclerview.widget.RecyclerView;
import org.joinmastodon.android.GlobalUserPreferences;
import org.joinmastodon.android.R;
import org.joinmastodon.android.api.requests.lists.GetLists;
import org.joinmastodon.android.api.requests.tags.GetFollowedHashtags;
import org.joinmastodon.android.model.Hashtag;
import org.joinmastodon.android.model.HeaderPaginationList;
import org.joinmastodon.android.model.ListTimeline;
import org.joinmastodon.android.model.TimelineDefinition;
import org.joinmastodon.android.ui.DividerItemDecoration;
import org.joinmastodon.android.ui.M3AlertDialogBuilder;
import org.joinmastodon.android.ui.utils.UiUtils;
import org.joinmastodon.android.ui.views.TextInputFrameLayout;
import java.util.ArrayList;
import java.util.Collections;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import me.grishka.appkit.api.Callback;
import me.grishka.appkit.api.ErrorResponse;
import me.grishka.appkit.fragments.BaseRecyclerFragment;
import me.grishka.appkit.utils.BindableViewHolder;
import me.grishka.appkit.views.UsableRecyclerView;
public class EditTimelinesFragment extends BaseRecyclerFragment<TimelineDefinition> implements ScrollableToTop {
private String accountID;
private TimelinesAdapter adapter;
private final ItemTouchHelper itemTouchHelper;
private Menu optionsMenu;
private boolean updated;
private final Map<MenuItem, TimelineDefinition> timelineByMenuItem = new HashMap<>();
private final List<ListTimeline> listTimelines = new ArrayList<>();
private final List<Hashtag> hashtags = new ArrayList<>();
public EditTimelinesFragment() {
super(10);
ItemTouchHelper.SimpleCallback itemTouchCallback = new ItemTouchHelperCallback() ;
itemTouchHelper = new ItemTouchHelper(itemTouchCallback);
}
@Override
public void onCreate(Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
setHasOptionsMenu(true);
setTitle(R.string.sk_timelines);
accountID = getArguments().getString("account");
new GetLists().setCallback(new Callback<>() {
@Override
public void onSuccess(List<ListTimeline> result) {
listTimelines.addAll(result);
updateOptionsMenu();
}
@Override
public void onError(ErrorResponse error) {
error.showToast(getContext());
}
}).exec(accountID);
new GetFollowedHashtags().setCallback(new Callback<>() {
@Override
public void onSuccess(HeaderPaginationList<Hashtag> result) {
hashtags.addAll(result);
updateOptionsMenu();
}
@Override
public void onError(ErrorResponse error) {
error.showToast(getContext());
}
}).exec(accountID);
}
@Override
protected void onShown(){
super.onShown();
if(!getArguments().getBoolean("noAutoLoad") && !loaded && !dataLoading) loadData();
}
@Override
public void onViewCreated(View view, Bundle savedInstanceState) {
super.onViewCreated(view, savedInstanceState);
itemTouchHelper.attachToRecyclerView(list);
refreshLayout.setEnabled(false);
list.addItemDecoration(new DividerItemDecoration(getActivity(), R.attr.colorPollVoted, 0.5f, 56, 16));
}
@Override
public void onCreateOptionsMenu(Menu menu, MenuInflater inflater) {
this.optionsMenu = menu;
updateOptionsMenu();
}
@Override
public boolean onOptionsItemSelected(MenuItem item) {
if (item.getItemId() == R.id.menu_back) {
updateOptionsMenu();
optionsMenu.performIdentifierAction(R.id.menu_add_timeline, 0);
return true;
}
TimelineDefinition tl = timelineByMenuItem.get(item);
if (tl != null) {
data.add(tl.copy());
adapter.notifyItemInserted(data.size());
saveTimelines();
updateOptionsMenu();
};
return true;
}
private void addTimelineToOptions(TimelineDefinition tl, Menu menu) {
if (data.contains(tl)) return;
MenuItem item = menu.add(0, View.generateViewId(), Menu.NONE, tl.getTitle(getContext()));
item.setIcon(tl.getIcon().iconRes);
timelineByMenuItem.put(item, tl);
}
private void updateOptionsMenu() {
optionsMenu.clear();
timelineByMenuItem.clear();
SubMenu menu = optionsMenu.addSubMenu(0, R.id.menu_add_timeline, NONE, R.string.sk_timelines_add);
menu.getItem().setShowAsAction(MenuItem.SHOW_AS_ACTION_ALWAYS);
menu.getItem().setIcon(R.drawable.ic_fluent_add_24_regular);
SubMenu timelinesMenu = menu.addSubMenu(R.string.sk_timeline);
timelinesMenu.getItem().setIcon(R.drawable.ic_fluent_timeline_24_regular);
SubMenu listsMenu = menu.addSubMenu(R.string.sk_list);
listsMenu.getItem().setIcon(R.drawable.ic_fluent_people_list_24_regular);
SubMenu hashtagsMenu = menu.addSubMenu(R.string.sk_hashtag);
hashtagsMenu.getItem().setIcon(R.drawable.ic_fluent_number_symbol_24_regular);
makeBackItem(timelinesMenu);
makeBackItem(listsMenu);
makeBackItem(hashtagsMenu);
TimelineDefinition.ALL_TIMELINES.forEach(tl -> addTimelineToOptions(tl, timelinesMenu));
listTimelines.stream().map(TimelineDefinition::ofList).forEach(tl -> addTimelineToOptions(tl, listsMenu));
hashtags.stream().map(TimelineDefinition::ofHashtag).forEach(tl -> addTimelineToOptions(tl, hashtagsMenu));
timelinesMenu.getItem().setVisible(timelinesMenu.size() > 0);
listsMenu.getItem().setVisible(listsMenu.size() > 0);
hashtagsMenu.getItem().setVisible(hashtagsMenu.size() > 0);
UiUtils.enableOptionsMenuIcons(getContext(), optionsMenu, R.id.menu_add_timeline);
}
private void saveTimelines() {
updated = true;
GlobalUserPreferences.pinnedTimelines.put(accountID, data.size() > 0 ? data : List.of(TimelineDefinition.HOME_TIMELINE));
GlobalUserPreferences.save();
}
private void removeTimeline(int position) {
data.remove(position);
adapter.notifyItemRemoved(position);
saveTimelines();
updateOptionsMenu();
}
@Override
protected void doLoadData(int offset, int count){
onDataLoaded(GlobalUserPreferences.pinnedTimelines.getOrDefault(accountID, TimelineDefinition.DEFAULT_TIMELINES), false);
updateOptionsMenu();
}
@Override
protected RecyclerView.Adapter<TimelineViewHolder> getAdapter() {
return adapter = new TimelinesAdapter();
}
@Override
public void scrollToTop() {
smoothScrollRecyclerViewToTop(list);
}
@Override
public void onDestroy() {
super.onDestroy();
if (updated) UiUtils.restartApp();
}
private class TimelinesAdapter extends RecyclerView.Adapter<TimelineViewHolder>{
@NonNull
@Override
public TimelineViewHolder onCreateViewHolder(@NonNull ViewGroup parent, int viewType){
return new TimelineViewHolder();
}
@Override
public void onBindViewHolder(@NonNull TimelineViewHolder holder, int position) {
holder.bind(data.get(position));
}
@Override
public int getItemCount() {
return data.size();
}
}
private class TimelineViewHolder extends BindableViewHolder<TimelineDefinition> implements UsableRecyclerView.Clickable{
private final TextView title;
private final ImageView dragger;
public TimelineViewHolder(){
super(getActivity(), R.layout.item_text, list);
title=findViewById(R.id.title);
dragger=findViewById(R.id.dragger_thingy);
}
@SuppressLint("ClickableViewAccessibility")
@Override
public void onBind(TimelineDefinition item) {
title.setText(item.getTitle(getContext()));
title.setCompoundDrawablesRelativeWithIntrinsicBounds(itemView.getContext().getDrawable(item.getIcon().iconRes), null, null, null);
dragger.setVisibility(View.VISIBLE);
dragger.setOnTouchListener((View v, MotionEvent event) -> {
if (event.getAction() == MotionEvent.ACTION_DOWN) {
itemTouchHelper.startDrag(this);
return true;
}
return false;
});
}
@SuppressLint("ClickableViewAccessibility")
@Override
public void onClick() {
Context ctx = getContext();
LinearLayout view = (LinearLayout) getActivity().getLayoutInflater()
.inflate(R.layout.edit_timeline, (ViewGroup) itemView, false);
TextInputFrameLayout inputLayout = view.findViewById(R.id.input);
EditText editText = inputLayout.getEditText();
editText.setText(item.getCustomTitle());
editText.setHint(item.getDefaultTitle(ctx));
ImageButton btn = view.findViewById(R.id.button);
PopupMenu popup = new PopupMenu(ctx, btn);
TimelineDefinition.Icon currentIcon = item.getIcon();
btn.setImageResource(currentIcon.iconRes);
btn.setContentDescription(ctx.getString(currentIcon.nameRes));
btn.setOnTouchListener(popup.getDragToOpenListener());
btn.setOnClickListener(l -> popup.show());
Menu menu = popup.getMenu();
TimelineDefinition.Icon defaultIcon = item.getDefaultIcon();
menu.add(0, currentIcon.ordinal(), NONE, currentIcon.nameRes).setIcon(currentIcon.iconRes);
if (!currentIcon.equals(defaultIcon)) {
menu.add(0, defaultIcon.ordinal(), NONE, defaultIcon.nameRes).setIcon(defaultIcon.iconRes);
}
for (TimelineDefinition.Icon icon : TimelineDefinition.Icon.values()) {
if (icon.hidden || icon.equals(item.getIcon())) continue;
menu.add(0, icon.ordinal(), NONE, icon.nameRes).setIcon(icon.iconRes);
}
UiUtils.enablePopupMenuIcons(ctx, popup);
popup.setOnMenuItemClickListener(menuItem -> {
TimelineDefinition.Icon icon = TimelineDefinition.Icon.values()[menuItem.getItemId()];
btn.setImageResource(icon.iconRes);
btn.setContentDescription(ctx.getString(icon.nameRes));
item.setIcon(icon);
return true;
});
new M3AlertDialogBuilder(ctx)
.setTitle(R.string.sk_edit_timeline)
.setView(view)
.setPositiveButton(R.string.save, (d, which) -> {
item.setTitle(editText.getText().toString().trim());
rebind();
saveTimelines();
})
.setNeutralButton(R.string.sk_remove, (d, which) ->
removeTimeline(getAbsoluteAdapterPosition()))
.setNegativeButton(R.string.cancel, (d, which) -> {})
.show();
editText.requestFocus();
}
}
private class ItemTouchHelperCallback extends ItemTouchHelper.SimpleCallback {
public ItemTouchHelperCallback() {
super(ItemTouchHelper.UP | ItemTouchHelper.DOWN, ItemTouchHelper.LEFT | ItemTouchHelper.RIGHT);
}
@Override
public boolean onMove(@NonNull RecyclerView recyclerView, @NonNull RecyclerView.ViewHolder viewHolder, @NonNull RecyclerView.ViewHolder target) {
int fromPosition = viewHolder.getAbsoluteAdapterPosition();
int toPosition = target.getAbsoluteAdapterPosition();
if (Math.max(fromPosition, toPosition) >= data.size() || Math.min(fromPosition, toPosition) < 0) {
return false;
} else {
Collections.swap(data, fromPosition, toPosition);
adapter.notifyItemMoved(fromPosition, toPosition);
saveTimelines();
return true;
}
}
@Override
public void onSelectedChanged(@Nullable RecyclerView.ViewHolder viewHolder, int actionState) {
if (actionState == ItemTouchHelper.ACTION_STATE_DRAG && viewHolder != null) {
viewHolder.itemView.animate().alpha(0.65f);
}
}
@Override
public void clearView(@NonNull RecyclerView recyclerView, @NonNull RecyclerView.ViewHolder viewHolder) {
super.clearView(recyclerView, viewHolder);
viewHolder.itemView.animate().alpha(1f);
}
@Override
public void onSwiped(@NonNull RecyclerView.ViewHolder viewHolder, int direction) {
int position = viewHolder.getAbsoluteAdapterPosition();
removeTimeline(position);
}
}
}

View File

@ -2,6 +2,7 @@ package org.joinmastodon.android.fragments;
import android.app.Activity;
import android.os.Bundle;
import android.view.HapticFeedbackConstants;
import android.view.Menu;
import android.view.MenuInflater;
import android.view.MenuItem;
@ -16,6 +17,7 @@ import org.joinmastodon.android.api.requests.tags.SetHashtagFollowed;
import org.joinmastodon.android.api.requests.timelines.GetHashtagTimeline;
import org.joinmastodon.android.model.Hashtag;
import org.joinmastodon.android.model.Status;
import org.joinmastodon.android.model.TimelineDefinition;
import org.joinmastodon.android.ui.utils.UiUtils;
import java.util.List;
@ -26,7 +28,7 @@ import me.grishka.appkit.api.ErrorResponse;
import me.grishka.appkit.api.SimpleCallback;
import me.grishka.appkit.utils.V;
public class HashtagTimelineFragment extends StatusListFragment{
public class HashtagTimelineFragment extends PinnableStatusListFragment {
private String hashtag;
private boolean following;
private ImageButton fab;
@ -41,7 +43,6 @@ public class HashtagTimelineFragment extends StatusListFragment{
super.onAttach(activity);
updateTitle(getArguments().getString("hashtag"));
following=getArguments().getBoolean("following", false);
setHasOptionsMenu(true);
}
@ -59,11 +60,31 @@ public class HashtagTimelineFragment extends StatusListFragment{
@Override
public void onCreateOptionsMenu(Menu menu, MenuInflater inflater) {
inflater.inflate(R.menu.hashtag_timeline, menu);
super.onCreateOptionsMenu(menu, inflater);
followButton = menu.findItem(R.id.follow_hashtag);
updateFollowingState(following);
followButton.setOnMenuItemClickListener(i -> {
new GetHashtag(hashtag).setCallback(new Callback<>() {
@Override
public void onSuccess(Hashtag hashtag) {
updateTitle(hashtag.name);
updateFollowingState(hashtag.following);
}
@Override
public void onError(ErrorResponse error) {
error.showToast(getActivity());
}
}).exec(accountID);
}
@Override
public boolean onOptionsItemSelected(MenuItem item) {
if (super.onOptionsItemSelected(item)) return true;
if (item.getItemId() == R.id.follow_hashtag) {
updateFollowingState(!following);
getToolbar().performHapticFeedback(HapticFeedbackConstants.CONTEXT_CLICK);
new SetHashtagFollowed(hashtag, following).setCallback(new Callback<>() {
@Override
public void onSuccess(Hashtag i) {
@ -78,20 +99,13 @@ public class HashtagTimelineFragment extends StatusListFragment{
}
}).exec(accountID);
return true;
});
}
return false;
}
new GetHashtag(hashtag).setCallback(new Callback<>() {
@Override
public void onSuccess(Hashtag hashtag) {
updateTitle(hashtag.name);
updateFollowingState(hashtag.following);
}
@Override
public void onError(ErrorResponse error) {
error.showToast(getActivity());
}
}).exec(accountID);
@Override
protected TimelineDefinition makeTimelineDefinition() {
return TimelineDefinition.ofHashtag(hashtag);
}
@Override

View File

@ -1,7 +1,5 @@
package org.joinmastodon.android.fragments;
import static org.joinmastodon.android.GlobalUserPreferences.showFederatedTimeline;
import android.animation.Animator;
import android.animation.AnimatorListenerAdapter;
import android.animation.AnimatorSet;
@ -42,12 +40,11 @@ import org.joinmastodon.android.api.requests.announcements.GetAnnouncements;
import org.joinmastodon.android.api.requests.lists.GetLists;
import org.joinmastodon.android.api.requests.tags.GetFollowedHashtags;
import org.joinmastodon.android.events.SelfUpdateStateChangedEvent;
import org.joinmastodon.android.fragments.discover.FederatedTimelineFragment;
import org.joinmastodon.android.fragments.discover.LocalTimelineFragment;
import org.joinmastodon.android.model.Announcement;
import org.joinmastodon.android.model.Hashtag;
import org.joinmastodon.android.model.HeaderPaginationList;
import org.joinmastodon.android.model.ListTimeline;
import org.joinmastodon.android.model.TimelineDefinition;
import org.joinmastodon.android.ui.SimpleViewHolder;
import org.joinmastodon.android.ui.utils.UiUtils;
import org.joinmastodon.android.updater.GithubSelfUpdater;
@ -67,6 +64,7 @@ import me.grishka.appkit.utils.V;
public class HomeTabFragment extends MastodonToolbarFragment implements ScrollableToTop, OnBackPressedListener {
private static final int ANNOUNCEMENTS_RESULT = 654;
private static final int PINNED_UPDATED_RESULT = 523;
private String accountID;
private MenuItem announcements;
@ -75,8 +73,6 @@ public class HomeTabFragment extends MastodonToolbarFragment implements Scrollab
private boolean newPostsBtnShown;
private AnimatorSet currentNewPostsAnim;
private ViewPager2 pager;
private final List<Fragment> fragments = new ArrayList<>();
private final List<FrameLayout> tabViews = new ArrayList<>();
private View switcher;
private FrameLayout toolbarFrame;
private ImageView timelineIcon;
@ -85,11 +81,24 @@ public class HomeTabFragment extends MastodonToolbarFragment implements Scrollab
private PopupMenu switcherPopup;
private final Map<Integer, ListTimeline> listItems = new HashMap<>();
private final Map<Integer, Hashtag> hashtagsItems = new HashMap<>();
private List<TimelineDefinition> timelineDefinitions;
private int count;
private Fragment[] fragments;
private FrameLayout[] tabViews;
private TimelineDefinition[] timelines;
private Map<Integer, TimelineDefinition> timelinesByMenuItem = new HashMap<>();
@Override
public void onCreate(Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
accountID = getArguments().getString("account");
timelineDefinitions = GlobalUserPreferences.pinnedTimelines.getOrDefault(accountID, TimelineDefinition.DEFAULT_TIMELINES);
assert timelineDefinitions != null;
if (timelineDefinitions.size() == 0) timelineDefinitions = List.of(TimelineDefinition.HOME_TIMELINE);
count = timelineDefinitions.size();
fragments = new Fragment[count];
tabViews = new FrameLayout[count];
timelines = new TimelineDefinition[count];
}
@Override
@ -104,30 +113,28 @@ public class HomeTabFragment extends MastodonToolbarFragment implements Scrollab
pager = new ViewPager2(getContext());
toolbarFrame = (FrameLayout) LayoutInflater.from(getContext()).inflate(R.layout.home_toolbar, getToolbar(), false);
if (fragments.size() == 0) {
if (fragments[0] == null) {
Bundle args = new Bundle();
args.putString("account", accountID);
args.putBoolean("__is_tab", true);
fragments.add(new HomeTimelineFragment());
fragments.add(new LocalTimelineFragment());
if (showFederatedTimeline) fragments.add(new FederatedTimelineFragment());
args=new Bundle(args);
args.putBoolean("onlyPosts", true);
NotificationsListFragment postsFragment=new NotificationsListFragment();
postsFragment.setArguments(args);
fragments.add(postsFragment);
for (int i = 0; i < timelineDefinitions.size(); i++) {
TimelineDefinition tl = timelineDefinitions.get(i);
fragments[i] = tl.getFragment();
timelines[i] = tl;
}
FragmentTransaction transaction = getChildFragmentManager().beginTransaction();
for (int i = 0; i < fragments.size(); i++) {
fragments.get(i).setArguments(args);
for (int i = 0; i < count; i++) {
fragments[i].setArguments(timelines[i].populateArguments(new Bundle(args)));
FrameLayout tabView = new FrameLayout(getActivity());
tabView.setLayoutParams(new ViewGroup.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.MATCH_PARENT));
tabView.setVisibility(View.GONE);
tabView.setId(i + 1);
transaction.add(i + 1, fragments.get(i));
transaction.add(i + 1, fragments[i]);
view.addView(tabView);
tabViews.add(tabView);
tabViews[i] = tabView;
}
transaction.commit();
}
@ -147,7 +154,6 @@ public class HomeTabFragment extends MastodonToolbarFragment implements Scrollab
collapsedChevron = toolbarFrame.findViewById(R.id.collapsed_chevron);
switcher = toolbarFrame.findViewById(R.id.switcher_btn);
switcherPopup = new PopupMenu(getContext(), switcher);
switcherPopup.inflate(R.menu.home_switcher);
switcherPopup.setOnMenuItemClickListener(this::onSwitcherItemSelected);
UiUtils.enablePopupMenuIcons(getContext(), switcherPopup);
switcher.setOnClickListener(v->{
@ -167,9 +173,8 @@ public class HomeTabFragment extends MastodonToolbarFragment implements Scrollab
@Override
public void onPageSelected(int position){
updateSwitcherIcon(position);
if (position==0) return;
hideNewPostsButton();
if (fragments.get(position) instanceof BaseRecyclerFragment<?> page){
if (!timelines[position].equals(TimelineDefinition.HOME_TIMELINE)) hideNewPostsButton();
if (fragments[position] instanceof BaseRecyclerFragment<?> page){
if(!page.loaded && !page.isDataLoading()) page.loadData();
}
}
@ -177,7 +182,7 @@ public class HomeTabFragment extends MastodonToolbarFragment implements Scrollab
if (!GlobalUserPreferences.reduceMotion) {
pager.setPageTransformer((v, pos) -> {
if (tabViews.get(pager.getCurrentItem()) != v) return;
if (tabViews[pager.getCurrentItem()] != v) return;
float scaleFactor = Math.max(0.85f, 1 - Math.abs(pos) * 0.06f);
switcher.setScaleY(scaleFactor);
switcher.setScaleX(scaleFactor);
@ -292,6 +297,7 @@ public class HomeTabFragment extends MastodonToolbarFragment implements Scrollab
public void onSuccess(List<Announcement> result) {
boolean hasUnread = result.stream().anyMatch(a -> !a.read);
announcements.setIcon(hasUnread ? R.drawable.ic_announcements_24_badged : R.drawable.ic_fluent_megaphone_24_regular);
updateBadgedOptionsItem(announcements, hasUnread);
}
@Override
@ -299,6 +305,17 @@ public class HomeTabFragment extends MastodonToolbarFragment implements Scrollab
error.showToast(getActivity());
}
}).exec(accountID);
UiUtils.enableOptionsMenuIcons(getContext(), menu);
}
private void updateBadgedOptionsItem(MenuItem item, boolean asAction) {
item.setShowAsAction(asAction ? MenuItem.SHOW_AS_ACTION_ALWAYS : MenuItem.SHOW_AS_ACTION_NEVER);
if (asAction) {
UiUtils.resetPopupItemTint(item);
} else {
UiUtils.insetPopupMenuIcon(getContext(), item);
}
}
private <T> void addItemsToMap(List<T> addItems, Map<Integer, T> items) {
@ -309,13 +326,24 @@ public class HomeTabFragment extends MastodonToolbarFragment implements Scrollab
private void updateSwitcherMenu() {
Context context = getContext();
switcherPopup.getMenu().findItem(R.id.federated).setVisible(showFederatedTimeline);
Menu switcherMenu = switcherPopup.getMenu();
switcherMenu.clear();
timelinesByMenuItem.clear();
for (TimelineDefinition tl : timelines) {
int menuItemId = View.generateViewId();
timelinesByMenuItem.put(menuItemId, tl);
MenuItem item = switcherMenu.add(0, menuItemId, 0, tl.getTitle(getContext()));
item.setIcon(tl.getIcon().iconRes);
UiUtils.insetPopupMenuIcon(getContext(), item);
}
if (!listItems.isEmpty()) {
MenuItem listsItem = switcherPopup.getMenu().findItem(R.id.lists);
listsItem.setVisible(true);
SubMenu listsMenu = listsItem.getSubMenu();
SubMenu listsMenu = switcherMenu.addSubMenu(R.string.sk_list_timelines);
UiUtils.insetPopupMenuIcon(context, listsMenu.getItem().setVisible(true)
.setIcon(R.drawable.ic_fluent_people_list_24_regular));
listsMenu.clear();
UiUtils.insetPopupMenuIcon(context, UiUtils.makeBackItem(listsMenu));
listItems.forEach((id, list) -> {
MenuItem item = listsMenu.add(Menu.NONE, id, Menu.NONE, list.title);
item.setIcon(R.drawable.ic_fluent_people_list_24_regular);
@ -324,10 +352,11 @@ public class HomeTabFragment extends MastodonToolbarFragment implements Scrollab
}
if (!hashtagsItems.isEmpty()) {
MenuItem hashtagsItem = switcherPopup.getMenu().findItem(R.id.followed_hashtags);
hashtagsItem.setVisible(true);
SubMenu hashtagsMenu = hashtagsItem.getSubMenu();
SubMenu hashtagsMenu = switcherMenu.addSubMenu(R.string.sk_hashtags_you_follow);
UiUtils.insetPopupMenuIcon(context, hashtagsMenu.getItem().setVisible(true)
.setIcon(R.drawable.ic_fluent_number_symbol_24_regular));
hashtagsMenu.clear();
UiUtils.insetPopupMenuIcon(context, UiUtils.makeBackItem(hashtagsMenu));
hashtagsItems.forEach((id, hashtag) -> {
MenuItem item = hashtagsMenu.add(Menu.NONE, id, Menu.NONE, hashtag.name);
item.setIcon(R.drawable.ic_fluent_number_symbol_24_regular);
@ -340,30 +369,35 @@ public class HomeTabFragment extends MastodonToolbarFragment implements Scrollab
int id = item.getItemId();
ListTimeline list;
Hashtag hashtag;
if (id == R.id.home) {
navigateTo(0);
Bundle args = new Bundle();
args.putString("account", accountID);
if (id == R.id.menu_back) {
switcher.post(() -> switcherPopup.show());
return true;
} else if (id == R.id.local) {
navigateTo(1);
return true;
} else if (id == R.id.federated) {
navigateTo(2);
return true;
} else if (id == R.id.post_notifications) {
navigateTo(showFederatedTimeline ? 3 : 2);
} else if ((list = listItems.get(id)) != null) {
Bundle args = new Bundle();
args.putString("account", accountID);
args.putString("listID", list.id);
args.putString("listTitle", list.title);
args.putInt("repliesPolicy", list.repliesPolicy.ordinal());
Nav.go(getActivity(), ListTimelineFragment.class, args);
Nav.goForResult(getActivity(), ListTimelineFragment.class, args, PINNED_UPDATED_RESULT, this);
} else if ((hashtag = hashtagsItems.get(id)) != null) {
UiUtils.openHashtagTimeline(getActivity(), accountID, hashtag.name, hashtag.following);
args.putString("hashtag", hashtag.name);
args.putBoolean("following", hashtag.following);
Nav.goForResult(getActivity(), HashtagTimelineFragment.class, args, PINNED_UPDATED_RESULT, this);
} else {
TimelineDefinition tl = timelinesByMenuItem.get(id);
if (tl != null) {
for (int i = 0; i < timelines.length; i++) {
if (timelines[i] == tl) {
navigateTo(i);
return true;
}
}
}
}
return false;
}
private void navigateTo(int i) {
navigateTo(i, !GlobalUserPreferences.reduceMotion);
}
@ -374,38 +408,28 @@ public class HomeTabFragment extends MastodonToolbarFragment implements Scrollab
}
private void updateSwitcherIcon(int i) {
// todo: refactor when implementing pinned tabs
if (i == (showFederatedTimeline ? 3 : 2)) {
timelineIcon.setImageResource(R.drawable.ic_fluent_alert_24_regular);
timelineTitle.setText(R.string.sk_notify_posts);
} else {
timelineIcon.setImageResource(switch (i) {
default -> R.drawable.ic_fluent_home_24_regular;
case 1 -> R.drawable.ic_fluent_people_community_24_regular;
case 2 -> R.drawable.ic_fluent_earth_24_regular;
});
timelineTitle.setText(switch (i) {
default -> R.string.sk_timeline_home;
case 1 -> R.string.sk_timeline_local;
case 2 -> R.string.sk_timeline_federated;
});
}
timelineIcon.setImageResource(timelines[i].getIcon().iconRes);
timelineTitle.setText(timelines[i].getTitle(getContext()));
}
@Override
public boolean onOptionsItemSelected(MenuItem item){
Bundle args=new Bundle();
args.putString("account", accountID);
if (item.getItemId() == R.id.settings) Nav.go(getActivity(), SettingsFragment.class, args);
if (item.getItemId() == R.id.announcements) {
int id = item.getItemId();
if (id == R.id.settings) {
Nav.go(getActivity(), SettingsFragment.class, args);
} else if (id == R.id.announcements) {
Nav.goForResult(getActivity(), AnnouncementsFragment.class, args, ANNOUNCEMENTS_RESULT, this);
} else if (id == R.id.edit_timelines) {
Nav.go(getActivity(), EditTimelinesFragment.class, args);
}
return true;
}
@Override
public void scrollToTop(){
((ScrollableToTop) fragments.get(pager.getCurrentItem())).scrollToTop();
((ScrollableToTop) fragments[pager.getCurrentItem()]).scrollToTop();
}
public void hideNewPostsButton(){
@ -441,7 +465,7 @@ public class HomeTabFragment extends MastodonToolbarFragment implements Scrollab
}
public void showNewPostsButton(){
if(newPostsBtnShown || pager == null || pager.getCurrentItem() != 0)
if(newPostsBtnShown || pager == null || !timelines[pager.getCurrentItem()].equals(TimelineDefinition.HOME_TIMELINE))
return;
newPostsBtnShown=true;
if(currentNewPostsAnim!=null){
@ -484,15 +508,22 @@ public class HomeTabFragment extends MastodonToolbarFragment implements Scrollab
}
@Override
public void onFragmentResult(int reqCode, boolean noMoreUnread, Bundle result){
if (reqCode == ANNOUNCEMENTS_RESULT && noMoreUnread) {
public void onFragmentResult(int reqCode, boolean success, Bundle result){
if (reqCode == ANNOUNCEMENTS_RESULT && success) {
announcements.setIcon(R.drawable.ic_fluent_megaphone_24_regular);
announcements.setShowAsAction(MenuItem.SHOW_AS_ACTION_NEVER);
UiUtils.insetPopupMenuIcon(getContext(), announcements);
} else if (reqCode == PINNED_UPDATED_RESULT && result != null && result.getBoolean("pinnedUpdated", false)) {
UiUtils.restartApp();
}
}
private void updateUpdateState(GithubSelfUpdater.UpdateState state){
if(state!=GithubSelfUpdater.UpdateState.NO_UPDATE && state!=GithubSelfUpdater.UpdateState.CHECKING)
getToolbar().getMenu().findItem(R.id.settings).setIcon(R.drawable.ic_settings_24_badged);
if(state!=GithubSelfUpdater.UpdateState.NO_UPDATE && state!=GithubSelfUpdater.UpdateState.CHECKING) {
MenuItem settings = getToolbar().getMenu().findItem(R.id.settings);
settings.setIcon(R.drawable.ic_settings_24_badged);
updateBadgedOptionsItem(settings, true);
}
}
@Subscribe
@ -534,7 +565,7 @@ public class HomeTabFragment extends MastodonToolbarFragment implements Scrollab
@NonNull
@Override
public SimpleViewHolder onCreateViewHolder(@NonNull ViewGroup parent, int viewType) {
FrameLayout tabView = tabViews.get(viewType % getItemCount());
FrameLayout tabView = tabViews[viewType % getItemCount()];
((ViewGroup)tabView.getParent()).removeView(tabView);
tabView.setVisibility(View.VISIBLE);
return new SimpleViewHolder(tabView);
@ -545,7 +576,7 @@ public class HomeTabFragment extends MastodonToolbarFragment implements Scrollab
@Override
public int getItemCount(){
return fragments.size();
return count;
}
@Override

View File

@ -2,23 +2,28 @@ package org.joinmastodon.android.fragments;
import android.app.Activity;
import android.os.Bundle;
import android.view.HapticFeedbackConstants;
import android.view.Menu;
import android.view.MenuInflater;
import android.view.MenuItem;
import android.view.View;
import android.view.ViewGroup;
import android.widget.ImageButton;
import android.widget.Toast;
import org.joinmastodon.android.GlobalUserPreferences;
import org.joinmastodon.android.R;
import org.joinmastodon.android.api.requests.lists.CreateList;
import org.joinmastodon.android.api.requests.lists.GetList;
import org.joinmastodon.android.api.requests.lists.UpdateList;
import org.joinmastodon.android.api.requests.timelines.GetListTimeline;
import org.joinmastodon.android.model.ListTimeline;
import org.joinmastodon.android.model.Status;
import org.joinmastodon.android.model.TimelineDefinition;
import org.joinmastodon.android.ui.M3AlertDialogBuilder;
import org.joinmastodon.android.ui.utils.UiUtils;
import org.joinmastodon.android.ui.views.ListTimelineEditor;
import java.util.ArrayList;
import java.util.List;
import me.grishka.appkit.Nav;
@ -28,11 +33,12 @@ import me.grishka.appkit.api.SimpleCallback;
import me.grishka.appkit.utils.V;
public class ListTimelineFragment extends StatusListFragment {
public class ListTimelineFragment extends PinnableStatusListFragment {
private String listID;
private String listTitle;
private ListTimeline.RepliesPolicy repliesPolicy;
private ImageButton fab;
private Bundle resultArgs = new Bundle();
public ListTimelineFragment() {
setListLayoutId(R.layout.recycler_fragment_with_fab);
@ -45,21 +51,36 @@ public class ListTimelineFragment extends StatusListFragment {
listID = args.getString("listID");
listTitle = args.getString("listTitle");
repliesPolicy = ListTimeline.RepliesPolicy.values()[args.getInt("repliesPolicy", 0)];
resultArgs.putString("listID", listID);
setTitle(listTitle);
setHasOptionsMenu(true);
new GetList(listID).setCallback(new Callback<>() {
@Override
public void onSuccess(ListTimeline listTimeline) {
// TODO: save updated info
if (!listTimeline.title.equals(listTitle)) setTitle(listTimeline.title);
if (!listTimeline.repliesPolicy.equals(repliesPolicy)) repliesPolicy = listTimeline.repliesPolicy;
}
@Override
public void onError(ErrorResponse error) {
error.showToast(getContext());
}
});
}
@Override
public void onCreateOptionsMenu(Menu menu, MenuInflater inflater) {
super.onCreateOptionsMenu(menu, inflater);
inflater.inflate(R.menu.list, menu);
super.onCreateOptionsMenu(menu, inflater);
UiUtils.enableOptionsMenuIcons(getContext(), menu, R.id.pin);
}
@Override
public boolean onOptionsItemSelected(MenuItem item) {
Bundle args = new Bundle();
args.putString("listID", listID);
if (super.onOptionsItemSelected(item)) return true;
if (item.getItemId() == R.id.edit) {
ListTimelineEditor editor = new ListTimelineEditor(getContext());
editor.applyList(listTitle, repliesPolicy);
@ -74,9 +95,9 @@ public class ListTimelineFragment extends StatusListFragment {
setTitle(list.title);
listTitle = list.title;
repliesPolicy = list.repliesPolicy;
args.putString("listTitle", listTitle);
args.putInt("repliesPolicy", repliesPolicy.ordinal());
setResult(true, args);
resultArgs.putString("listTitle", listTitle);
resultArgs.putInt("repliesPolicy", repliesPolicy.ordinal());
setResult(true, resultArgs);
}
@Override
@ -89,14 +110,24 @@ public class ListTimelineFragment extends StatusListFragment {
.show();
} else if (item.getItemId() == R.id.delete) {
UiUtils.confirmDeleteList(getActivity(), accountID, listID, listTitle, () -> {
args.putBoolean("deleted", true);
setResult(true, args);
resultArgs.putBoolean("deleted", true);
setResult(true, resultArgs);
Nav.finish(this);
});
}
return true;
}
@Override
public Bundle getResultArgs() {
return resultArgs;
}
@Override
protected TimelineDefinition makeTimelineDefinition() {
return TimelineDefinition.ofList(listID, listTitle);
}
@Override
protected void doLoadData(int offset, int count) {
currentRequest=new GetListTimeline(listID, offset==0 ? null : getMaxID(), null, count, null)

View File

@ -21,6 +21,7 @@ import org.joinmastodon.android.api.requests.lists.RemoveAccountsFromList;
import org.joinmastodon.android.model.ListTimeline;
import org.joinmastodon.android.ui.DividerItemDecoration;
import org.joinmastodon.android.ui.M3AlertDialogBuilder;
import org.joinmastodon.android.ui.utils.UiUtils;
import org.joinmastodon.android.ui.views.ListTimelineEditor;
import java.util.ArrayList;
@ -46,6 +47,7 @@ public class ListTimelinesFragment extends BaseRecyclerFragment<ListTimeline> im
private HashMap<String, Boolean> userInList = new HashMap<>();
private int inProgress = 0;
private ListsAdapter adapter;
private boolean pinnedUpdated;
public ListTimelinesFragment() {
super(10);
@ -74,6 +76,12 @@ public class ListTimelinesFragment extends BaseRecyclerFragment<ListTimeline> im
loadData();
}
@Override
public void onDestroy() {
super.onDestroy();
if (pinnedUpdated) UiUtils.restartApp();
}
@Override
public void onViewCreated(View view, Bundle savedInstanceState) {
super.onViewCreated(view, savedInstanceState);
@ -159,6 +167,7 @@ public class ListTimelinesFragment extends BaseRecyclerFragment<ListTimeline> im
@Override
public void onFragmentResult(int reqCode, boolean listChanged, Bundle result){
if (reqCode == LIST_CHANGED_RESULT && listChanged) {
if (result.getBoolean("pinnedUpdated")) pinnedUpdated = true;
String listID = result.getString("listID");
for (int i = 0; i < data.size(); i++) {
ListTimeline item = data.get(i);

View File

@ -0,0 +1,82 @@
package org.joinmastodon.android.fragments;
import android.os.Bundle;
import android.view.HapticFeedbackConstants;
import android.view.Menu;
import android.view.MenuInflater;
import android.view.MenuItem;
import android.widget.Toast;
import org.joinmastodon.android.GlobalUserPreferences;
import org.joinmastodon.android.R;
import org.joinmastodon.android.model.TimelineDefinition;
import java.util.ArrayList;
import java.util.List;
public abstract class PinnableStatusListFragment extends StatusListFragment {
protected boolean pinnedUpdated;
protected List<TimelineDefinition> pinnedTimelines;
@Override
public void onCreate(Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
pinnedTimelines = new ArrayList<>(GlobalUserPreferences.pinnedTimelines.getOrDefault(accountID, TimelineDefinition.DEFAULT_TIMELINES));
}
@Override
public void onCreateOptionsMenu(Menu menu, MenuInflater inflater) {
super.onCreateOptionsMenu(menu, inflater);
updatePinButton(menu.findItem(R.id.pin));
}
protected boolean isPinned() {
return pinnedTimelines.contains(makeTimelineDefinition());
}
protected void updatePinButton(MenuItem pin) {
boolean pinned = isPinned();
pin.setIcon(pinned ?
R.drawable.ic_fluent_pin_24_filled :
R.drawable.ic_fluent_pin_24_regular);
pin.setTitle(pinned ? R.string.sk_unpin_timeline : R.string.sk_pin_timeline);
}
protected abstract TimelineDefinition makeTimelineDefinition();
@Override
public boolean onOptionsItemSelected(MenuItem item) {
if (item.getItemId() == R.id.pin) {
togglePin(item);
return true;
}
return super.onOptionsItemSelected(item);
}
protected void togglePin(MenuItem pin) {
pinnedUpdated = true;
getToolbar().performHapticFeedback(HapticFeedbackConstants.CONTEXT_CLICK);
TimelineDefinition def = makeTimelineDefinition();
boolean pinned = isPinned();
if (pinned) pinnedTimelines.remove(def);
else pinnedTimelines.add(def);
Toast.makeText(getContext(), pinned ? R.string.sk_unpinned_timeline : R.string.sk_pinned_timeline, Toast.LENGTH_SHORT).show();
GlobalUserPreferences.pinnedTimelines.put(accountID, pinnedTimelines);
GlobalUserPreferences.save();
updatePinButton(pin);
}
protected Bundle getResultArgs() {
return new Bundle();
}
@Override
public void onDestroy() {
super.onDestroy();
Bundle resultArgs = getResultArgs();
if (pinnedUpdated) {
resultArgs.putBoolean("pinnedUpdated", true);
setResult(true, resultArgs);
}
}
}

View File

@ -165,11 +165,6 @@ public class SettingsFragment extends MastodonToolbarFragment{
}));
items.add(new HeaderItem(R.string.settings_behavior));
items.add(new SwitchItem(R.string.sk_settings_show_federated_timeline, R.drawable.ic_fluent_earth_24_regular, GlobalUserPreferences.showFederatedTimeline, i->{
GlobalUserPreferences.showFederatedTimeline=i.checked;
GlobalUserPreferences.save();
needAppRestart=true;
}));
items.add(new SwitchItem(R.string.settings_gif, R.drawable.ic_fluent_gif_24_regular, GlobalUserPreferences.playGifs, i->{
GlobalUserPreferences.playGifs=i.checked;
GlobalUserPreferences.save();
@ -321,11 +316,7 @@ public class SettingsFragment extends MastodonToolbarFragment{
if(needUpdateNotificationSettings && PushSubscriptionManager.arePushNotificationsAvailable()){
AccountSessionManager.getInstance().getAccount(accountID).getPushSubscriptionManager().updatePushSettings(pushSubscription);
}
if(needAppRestart){
Intent intent = Intent.makeRestartActivityTask(MastodonApp.context.getPackageManager().getLaunchIntentForPackage(MastodonApp.context.getPackageName()).getComponent());
MastodonApp.context.startActivity(intent);
Runtime.getRuntime().exit(0);
}
if(needAppRestart) UiUtils.restartApp();
}
@Override

View File

@ -0,0 +1,204 @@
package org.joinmastodon.android.model;
import android.app.Fragment;
import android.content.Context;
import android.os.Bundle;
import androidx.annotation.DrawableRes;
import androidx.annotation.Nullable;
import androidx.annotation.StringRes;
import org.joinmastodon.android.BuildConfig;
import org.joinmastodon.android.R;
import org.joinmastodon.android.fragments.HashtagTimelineFragment;
import org.joinmastodon.android.fragments.HomeTimelineFragment;
import org.joinmastodon.android.fragments.ListTimelineFragment;
import org.joinmastodon.android.fragments.NotificationsListFragment;
import org.joinmastodon.android.fragments.discover.FederatedTimelineFragment;
import org.joinmastodon.android.fragments.discover.LocalTimelineFragment;
import java.util.List;
import java.util.Objects;
public class TimelineDefinition {
private TimelineType type;
private String title;
private @Nullable Icon icon;
private @Nullable String listId;
private @Nullable String listTitle;
private @Nullable String hashtagName;
public static TimelineDefinition ofList(String listId, String listTitle) {
TimelineDefinition def = new TimelineDefinition(TimelineType.LIST, listTitle);
def.listId = listId;
def.listTitle = listTitle;
return def;
}
public static TimelineDefinition ofList(ListTimeline list) {
return ofList(list.id, list.title);
}
public static TimelineDefinition ofHashtag(String hashtag) {
TimelineDefinition def = new TimelineDefinition(TimelineType.HASHTAG, hashtag);
def.hashtagName = hashtag;
return def;
}
public static TimelineDefinition ofHashtag(Hashtag hashtag) {
return ofHashtag(hashtag.name);
}
@SuppressWarnings("unused")
public TimelineDefinition() {}
public TimelineDefinition(TimelineType type) {
this.type = type;
}
public TimelineDefinition(TimelineType type, String title) {
this.type = type;
this.title = title;
}
public String getTitle(Context ctx) {
return title != null ? title : getDefaultTitle(ctx);
}
public String getCustomTitle() {
return title;
}
public void setTitle(String title) {
this.title = title == null || title.isBlank() ? null : title;
}
public String getDefaultTitle(Context ctx) {
return switch (type) {
case HOME -> ctx.getString(R.string.sk_timeline_home);
case LOCAL -> ctx.getString(R.string.sk_timeline_local);
case FEDERATED -> ctx.getString(R.string.sk_timeline_federated);
case POST_NOTIFICATIONS -> ctx.getString(R.string.sk_timeline_posts);
case LIST -> listTitle;
case HASHTAG -> hashtagName;
};
}
public Icon getDefaultIcon() {
return switch (type) {
case HOME -> Icon.HOME;
case LOCAL -> Icon.LOCAL;
case FEDERATED -> Icon.FEDERATED;
case POST_NOTIFICATIONS -> Icon.POST_NOTIFICATIONS;
case LIST -> Icon.LIST;
case HASHTAG -> Icon.HASHTAG;
};
}
public Fragment getFragment() {
return switch (type) {
case HOME -> new HomeTimelineFragment();
case LOCAL -> new LocalTimelineFragment();
case FEDERATED -> new FederatedTimelineFragment();
case LIST -> new ListTimelineFragment();
case HASHTAG -> new HashtagTimelineFragment();
case POST_NOTIFICATIONS -> new NotificationsListFragment();
};
}
@Nullable
public Icon getIcon() {
return icon == null ? getDefaultIcon() : icon;
}
public void setIcon(@Nullable Icon icon) {
this.icon = icon;
}
public TimelineType getType() {
return type;
}
@Override
public boolean equals(Object o) {
if (this == o) return true;
if (o == null || getClass() != o.getClass()) return false;
TimelineDefinition that = (TimelineDefinition) o;
if (type != that.type) return false;
if (type == TimelineType.LIST) return Objects.equals(listId, that.listId);
if (type == TimelineType.HASHTAG) return Objects.equals(hashtagName.toLowerCase(), that.hashtagName.toLowerCase());
return true;
}
@Override
public int hashCode() {
int result = type.ordinal();
result = 31 * result + (listId != null ? listId.hashCode() : 0);
result = 31 * result + (hashtagName.toLowerCase() != null ? hashtagName.toLowerCase().hashCode() : 0);
return result;
}
public TimelineDefinition copy() {
TimelineDefinition def = new TimelineDefinition(type, title);
def.listId = listId;
def.listTitle = listTitle;
def.hashtagName = hashtagName;
def.icon = icon == null ? null : Icon.values()[icon.ordinal()];
return def;
}
public Bundle populateArguments(Bundle args) {
if (type == TimelineType.LIST) {
args.putString("listTitle", title);
args.putString("listID", listId);
} else if (type == TimelineType.HASHTAG) {
args.putString("hashtag", hashtagName);
}
return args;
}
public enum TimelineType { HOME, LOCAL, FEDERATED, POST_NOTIFICATIONS, LIST, HASHTAG }
public enum Icon {
HEART(R.drawable.ic_fluent_heart_24_regular, R.string.sk_icon_heart),
STAR(R.drawable.ic_fluent_star_24_regular, R.string.sk_icon_star),
HOME(R.drawable.ic_fluent_home_24_regular, R.string.sk_timeline_home, true),
LOCAL(R.drawable.ic_fluent_people_community_24_regular, R.string.sk_timeline_local, true),
FEDERATED(R.drawable.ic_fluent_earth_24_regular, R.string.sk_timeline_federated, true),
POST_NOTIFICATIONS(R.drawable.ic_fluent_alert_24_regular, R.string.sk_timeline_posts, true),
LIST(R.drawable.ic_fluent_people_list_24_regular, R.string.sk_list, true),
HASHTAG(R.drawable.ic_fluent_number_symbol_24_regular, R.string.sk_hashtag, true);
public final int iconRes, nameRes;
public final boolean hidden;
Icon(@DrawableRes int iconRes, @StringRes int nameRes) {
this(iconRes, nameRes, false);
}
Icon(@DrawableRes int iconRes, @StringRes int nameRes, boolean hidden) {
this.iconRes = iconRes;
this.nameRes = nameRes;
this.hidden = hidden;
}
}
public static final TimelineDefinition HOME_TIMELINE = new TimelineDefinition(TimelineType.HOME);
public static final TimelineDefinition LOCAL_TIMELINE = new TimelineDefinition(TimelineType.LOCAL);
public static final TimelineDefinition FEDERATED_TIMELINE = new TimelineDefinition(TimelineType.FEDERATED);
public static final TimelineDefinition POSTS_TIMELINE = new TimelineDefinition(TimelineType.POST_NOTIFICATIONS);
public static final List<TimelineDefinition> DEFAULT_TIMELINES = BuildConfig.BUILD_TYPE.equals("playRelease")
? List.of(HOME_TIMELINE.copy(), LOCAL_TIMELINE.copy())
: List.of(HOME_TIMELINE.copy(), LOCAL_TIMELINE.copy(), FEDERATED_TIMELINE.copy());
public static final List<TimelineDefinition> ALL_TIMELINES = List.of(
HOME_TIMELINE.copy(),
LOCAL_TIMELINE.copy(),
FEDERATED_TIMELINE.copy(),
POSTS_TIMELINE.copy()
);
}

View File

@ -1,5 +1,6 @@
package org.joinmastodon.android.ui.utils;
import static android.view.Menu.NONE;
import static org.joinmastodon.android.GlobalUserPreferences.theme;
import static org.joinmastodon.android.GlobalUserPreferences.trueBlackTheme;
@ -35,6 +36,7 @@ import android.util.Log;
import android.view.HapticFeedbackConstants;
import android.view.Menu;
import android.view.MenuItem;
import android.view.SubMenu;
import android.view.View;
import android.webkit.MimeTypeMap;
import android.widget.Button;
@ -772,11 +774,20 @@ public class UiUtils{
item.setTitle(ssb);
}
public static void resetPopupItemTint(MenuItem item) {
if(Build.VERSION.SDK_INT>=26) {
item.setIconTintList(null);
} else {
Drawable icon=item.getIcon().mutate();
icon.setTintList(null);
item.setIcon(icon);
}
}
public static void enableOptionsMenuIcons(Context context, Menu menu, @IdRes int... asAction) {
if(menu.getClass().getSimpleName().equals("MenuBuilder")){
try {
Method m = menu.getClass().getDeclaredMethod(
"setOptionalIconsVisible", Boolean.TYPE);
Method m = menu.getClass().getDeclaredMethod("setOptionalIconsVisible", Boolean.TYPE);
m.setAccessible(true);
m.invoke(menu, true);
enableMenuIcons(context, menu, asAction);
@ -789,6 +800,8 @@ public class UiUtils{
ColorStateList iconTint=ColorStateList.valueOf(UiUtils.getThemeColor(context, android.R.attr.textColorSecondary));
for(int i=0;i<m.size();i++){
MenuItem item=m.getItem(i);
SubMenu subMenu = item.getSubMenu();
if (subMenu != null) enableMenuIcons(context, subMenu, exclude);
if (item.getIcon() == null || Arrays.stream(exclude).anyMatch(id -> id == item.getItemId())) continue;
insetPopupMenuIcon(item, iconTint);
}
@ -887,6 +900,18 @@ public class UiUtils{
builder.show();
}
public static void restartApp() {
Intent intent = Intent.makeRestartActivityTask(MastodonApp.context.getPackageManager().getLaunchIntentForPackage(MastodonApp.context.getPackageName()).getComponent());
MastodonApp.context.startActivity(intent);
Runtime.getRuntime().exit(0);
}
public static MenuItem makeBackItem(Menu m) {
MenuItem back = m.add(0, R.id.menu_back, NONE, R.string.back);
back.setIcon(R.drawable.ic_arrow_back);
return back;
}
@FunctionalInterface
public interface InteractionPerformer {
void interact(StatusInteractionController ic, Status status, Consumer<Status> resultConsumer);

View File

@ -0,0 +1,3 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android" android:width="24dp" android:height="24dp" android:viewportWidth="24" android:viewportHeight="24">
<path android:pathData="M11.75 3c0.38 0 0.693 0.282 0.743 0.648L12.5 3.75 12.501 11h7.253c0.415 0 0.75 0.336 0.75 0.75 0 0.38-0.282 0.694-0.648 0.743L19.754 12.5h-7.253l0.002 7.25c0 0.413-0.335 0.75-0.75 0.75-0.38 0-0.693-0.283-0.743-0.649l-0.007-0.102-0.002-7.249H3.752c-0.414 0-0.75-0.336-0.75-0.75 0-0.38 0.282-0.694 0.648-0.743L3.752 11h7.25L11 3.75C11 3.336 11.336 3 11.75 3z" android:fillColor="@color/fluent_default_icon_tint"/>
</vector>

View File

@ -0,0 +1,3 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android" android:width="24dp" android:height="24dp" android:viewportWidth="24" android:viewportHeight="24">
<path android:pathData="M21.068 7.758l-4.826-4.826c-1.327-1.327-3.564-0.964-4.404 0.715l-2.435 4.87c-0.088 0.176-0.24 0.31-0.426 0.374l-4.166 1.44c-0.873 0.3-1.129 1.412-0.476 2.065L7.439 15.5 3 19.94V21h1.06l4.44-4.44 3.104 3.105c0.653 0.653 1.764 0.397 2.066-0.476l1.44-4.166c0.063-0.185 0.197-0.338 0.373-0.426l4.87-2.435c1.68-0.84 2.042-3.077 0.715-4.404z" android:fillColor="@color/fluent_default_icon_tint"/>
</vector>

View File

@ -0,0 +1,3 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android" android:width="24dp" android:height="24dp" android:viewportWidth="24" android:viewportHeight="24">
<path android:pathData="M15.25 13c0.967 0 1.75 0.784 1.75 1.75v4.5c0 0.966-0.783 1.75-1.75 1.75H3.75C2.785 21 2 20.216 2 19.25v-4.5C2 13.784 2.785 13 3.75 13h11.5zM21 14.899v5.351c0 0.414-0.335 0.75-0.75 0.75-0.38 0-0.693-0.282-0.743-0.648L19.5 20.25v-5.338C19.732 14.969 19.975 15 20.226 15c0.268 0 0.527-0.035 0.775-0.101zM15.25 14.5H3.75c-0.138 0-0.25 0.112-0.25 0.25v4.5c0 0.138 0.112 0.25 0.25 0.25h11.5c0.139 0 0.25-0.112 0.25-0.25v-4.5c0-0.138-0.111-0.25-0.25-0.25zm5-4.408c1.054 0 1.908 0.854 1.908 1.908 0 1.054-0.854 1.908-1.908 1.908-1.053 0-1.908-0.854-1.908-1.908 0-1.054 0.855-1.908 1.908-1.908zM15.246 3c0.967 0 1.75 0.784 1.75 1.75v4.5c0 0.966-0.783 1.75-1.75 1.75h-11.5c-0.966 0-1.75-0.784-1.75-1.75v-4.5c0-0.918 0.707-1.671 1.607-1.744L3.746 3h11.5zm0 1.5h-11.5L3.69 4.507C3.579 4.533 3.496 4.632 3.496 4.75v4.5c0 0.138 0.112 0.25 0.25 0.25h11.5c0.138 0 0.25-0.112 0.25-0.25v-4.5c0-0.138-0.112-0.25-0.25-0.25zM20.25 3c0.38 0 0.694 0.282 0.744 0.648L21 3.75v5.351C20.754 9.035 20.495 9 20.227 9c-0.25 0-0.494 0.03-0.726 0.088V3.75C19.5 3.336 19.836 3 20.25 3z" android:fillColor="@color/fluent_default_icon_tint"/>
</vector>

View File

@ -0,0 +1,22 @@
<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
android:orientation="horizontal"
android:layout_width="match_parent"
android:layout_height="wrap_content">
<ImageButton
style="?android:editTextStyle"
android:id="@+id/button"
android:contentDescription="@string/sk_timeline_icon"
android:paddingHorizontal="14dp"
android:layout_width="wrap_content"
android:layout_height="match_parent"
android:layout_marginTop="4dp"
android:layout_marginStart="24dp"
android:layout_marginBottom="16dp" />
<org.joinmastodon.android.ui.views.TextInputFrameLayout
android:id="@+id/input"
android:layout_marginStart="-8dp"
android:layout_weight="1"
android:layout_width="0dp"
android:layout_height="wrap_content" />
</LinearLayout>

View File

@ -26,6 +26,17 @@
android:layout_width="wrap_content"
android:layout_height="match_parent"
android:visibility="gone"
android:paddingRight="16dp"/>
android:paddingHorizontal="8dp"
tools:ignore="RtlSymmetry" />
<ImageView
android:id="@+id/dragger_thingy"
android:layout_width="56dp"
android:layout_height="56dp"
android:scaleType="center"
android:tint="?colorDarkIcon"
android:importantForAccessibility="no"
android:src="@drawable/ic_fluent_re_order_dots_vertical_24_regular"
android:visibility="gone" />
</LinearLayout>

View File

@ -1,5 +1,10 @@
<?xml version="1.0" encoding="utf-8"?>
<menu xmlns:android="http://schemas.android.com/apk/res/android">
<item
android:id="@+id/pin"
android:title="@string/sk_pin_timeline"
android:icon="@drawable/ic_fluent_pin_24_regular"
android:showAsAction="always" />
<item
android:id="@+id/follow_hashtag"
android:icon="@drawable/ic_fluent_person_add_24_regular"

View File

@ -3,11 +3,13 @@
<item
android:id="@+id/announcements"
android:icon="@drawable/ic_fluent_megaphone_24_regular"
android:showAsAction="always"
android:title="@string/sk_announcements" />
<item
android:id="@+id/edit_timelines"
android:icon="@drawable/ic_fluent_edit_24_regular"
android:title="@string/sk_edit_timelines" />
<item
android:id="@+id/settings"
android:icon="@drawable/ic_fluent_settings_24_regular"
android:showAsAction="always"
android:title="@string/settings" />
</menu>

View File

@ -1,13 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<menu xmlns:android="http://schemas.android.com/apk/res/android">
<item android:id="@+id/home" android:icon="@drawable/ic_fluent_home_24_regular" android:title="@string/sk_timeline_home" />
<item android:id="@+id/local" android:icon="@drawable/ic_fluent_people_community_24_regular" android:title="@string/sk_timeline_local" />
<item android:id="@+id/federated" android:icon="@drawable/ic_fluent_earth_24_regular" android:title="@string/sk_timeline_federated" />
<item android:id="@+id/post_notifications" android:icon="@drawable/ic_fluent_alert_24_regular" android:title="@string/sk_notify_posts" />
<item android:id="@+id/lists" android:icon="@drawable/ic_fluent_people_list_24_regular" android:title="@string/sk_list_timelines" android:visible="false">
<menu />
</item>
<item android:id="@+id/followed_hashtags" android:icon="@drawable/ic_fluent_number_symbol_24_regular" android:title="@string/sk_hashtags_you_follow" android:visible="false">
<menu />
</item>
</menu>

View File

@ -1,13 +1,16 @@
<?xml version="1.0" encoding="utf-8"?>
<menu xmlns:android="http://schemas.android.com/apk/res/android">
<item
android:id="@+id/pin"
android:title="@string/sk_pin_timeline"
android:icon="@drawable/ic_fluent_pin_24_regular"
android:showAsAction="always" />
<item
android:id="@+id/edit"
android:title="@string/edit"
android:icon="@drawable/ic_fluent_edit_24_regular"
android:showAsAction="always"/>
android:icon="@drawable/ic_fluent_edit_24_regular" />
<item
android:id="@+id/delete"
android:title="@string/delete"
android:icon="@drawable/ic_fluent_delete_24_regular"
android:showAsAction="always"/>
android:icon="@drawable/ic_fluent_delete_24_regular" />
</menu>

View File

@ -19,5 +19,6 @@
<item name="notifications_all" type="id"/>
<item name="notifications_mentions" type="id"/>
<item name="timeline_home" type="id" />
<item name="menu_add_timeline" type="id" />
<item name="menu_back" type="id" />
</resources>

View File

@ -154,4 +154,20 @@
<string name="sk_publish_anyway">Publish anyway</string>
<string name="sk_settings_disable_alt_text_reminder">Disable alt text reminder</string>
<string name="sk_notify_posts_info_banner">If you enable post notifications for some people, their new posts will appear here.</string>
<string name="sk_timelines">Timelines</string>
<string name="sk_timeline_posts">Posts</string>
<string name="sk_timelines_add">Add</string>
<string name="sk_timeline">Timeline</string>
<string name="sk_list">List</string>
<string name="sk_hashtag">Hashtag</string>
<string name="sk_pin_timeline">Pin timeline</string>
<string name="sk_unpin_timeline">Unpin timeline</string>
<string name="sk_pinned_timeline">Pinned to home</string>
<string name="sk_unpinned_timeline">Unpinned from home</string>
<string name="sk_remove">Remove</string>
<string name="sk_timeline_icon">Icon</string>
<string name="sk_icon_heart">Heart</string>
<string name="sk_icon_star">Star</string>
<string name="sk_edit_timeline">Edit timeline</string>
<string name="sk_edit_timelines">Edit timelines</string>
</resources>