From 88851a085e84348b141372dd0288c92cb4424d12 Mon Sep 17 00:00:00 2001 From: sk22 Date: Sat, 21 Jan 2023 02:17:47 +0100 Subject: [PATCH] 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 --- .../android/GlobalUserPreferences.java | 12 +- .../android/api/requests/lists/GetList.java | 10 + .../fragments/EditTimelinesFragment.java | 351 ++++++++++++++++++ .../fragments/HashtagTimelineFragment.java | 46 ++- .../android/fragments/HomeTabFragment.java | 175 +++++---- .../fragments/ListTimelineFragment.java | 51 ++- .../fragments/ListTimelinesFragment.java | 9 + .../fragments/PinnableStatusListFragment.java | 82 ++++ .../android/fragments/SettingsFragment.java | 11 +- .../android/model/TimelineDefinition.java | 204 ++++++++++ .../android/ui/utils/UiUtils.java | 29 +- .../res/drawable/ic_fluent_add_24_regular.xml | 3 + .../res/drawable/ic_fluent_pin_24_filled.xml | 3 + .../ic_fluent_timeline_24_regular.xml | 3 + .../src/main/res/layout/edit_timeline.xml | 22 ++ mastodon/src/main/res/layout/item_text.xml | 13 +- .../src/main/res/menu/hashtag_timeline.xml | 5 + mastodon/src/main/res/menu/home.xml | 6 +- mastodon/src/main/res/menu/home_switcher.xml | 13 - mastodon/src/main/res/menu/list.xml | 11 +- mastodon/src/main/res/values/ids.xml | 3 +- mastodon/src/main/res/values/strings_sk.xml | 16 + 22 files changed, 943 insertions(+), 135 deletions(-) create mode 100644 mastodon/src/main/java/org/joinmastodon/android/api/requests/lists/GetList.java create mode 100644 mastodon/src/main/java/org/joinmastodon/android/fragments/EditTimelinesFragment.java create mode 100644 mastodon/src/main/java/org/joinmastodon/android/fragments/PinnableStatusListFragment.java create mode 100644 mastodon/src/main/java/org/joinmastodon/android/model/TimelineDefinition.java create mode 100644 mastodon/src/main/res/drawable/ic_fluent_add_24_regular.xml create mode 100644 mastodon/src/main/res/drawable/ic_fluent_pin_24_filled.xml create mode 100644 mastodon/src/main/res/drawable/ic_fluent_timeline_24_regular.xml create mode 100644 mastodon/src/main/res/layout/edit_timeline.xml delete mode 100644 mastodon/src/main/res/menu/home_switcher.xml diff --git a/mastodon/src/main/java/org/joinmastodon/android/GlobalUserPreferences.java b/mastodon/src/main/java/org/joinmastodon/android/GlobalUserPreferences.java index 14f0e4543..438fb007e 100644 --- a/mastodon/src/main/java/org/joinmastodon/android/GlobalUserPreferences.java +++ b/mastodon/src/main/java/org/joinmastodon/android/GlobalUserPreferences.java @@ -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>>() {}.getType(); + private final static Type pinnedTimelinesType = new TypeToken>>() {}.getType(); public static Map> recentLanguages; + public static Map> pinnedTimelines; private static SharedPreferences getPrefs(){ return MastodonApp.context.getSharedPreferences("global", Context.MODE_PRIVATE); } private static 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(); } diff --git a/mastodon/src/main/java/org/joinmastodon/android/api/requests/lists/GetList.java b/mastodon/src/main/java/org/joinmastodon/android/api/requests/lists/GetList.java new file mode 100644 index 000000000..19bda79ca --- /dev/null +++ b/mastodon/src/main/java/org/joinmastodon/android/api/requests/lists/GetList.java @@ -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 { + public GetList(String id) { + super(HttpMethod.GET, "/lists/" + id, ListTimeline.class); + } +} diff --git a/mastodon/src/main/java/org/joinmastodon/android/fragments/EditTimelinesFragment.java b/mastodon/src/main/java/org/joinmastodon/android/fragments/EditTimelinesFragment.java new file mode 100644 index 000000000..292fe1e74 --- /dev/null +++ b/mastodon/src/main/java/org/joinmastodon/android/fragments/EditTimelinesFragment.java @@ -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 implements ScrollableToTop { + private String accountID; + private TimelinesAdapter adapter; + private final ItemTouchHelper itemTouchHelper; + private Menu optionsMenu; + private boolean updated; + private final Map timelineByMenuItem = new HashMap<>(); + private final List listTimelines = new ArrayList<>(); + private final List 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 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 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 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{ + @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 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); + } + } +} diff --git a/mastodon/src/main/java/org/joinmastodon/android/fragments/HashtagTimelineFragment.java b/mastodon/src/main/java/org/joinmastodon/android/fragments/HashtagTimelineFragment.java index 74c3185ed..08aed61fa 100644 --- a/mastodon/src/main/java/org/joinmastodon/android/fragments/HashtagTimelineFragment.java +++ b/mastodon/src/main/java/org/joinmastodon/android/fragments/HashtagTimelineFragment.java @@ -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 diff --git a/mastodon/src/main/java/org/joinmastodon/android/fragments/HomeTabFragment.java b/mastodon/src/main/java/org/joinmastodon/android/fragments/HomeTabFragment.java index 55602ac8c..e897aca30 100644 --- a/mastodon/src/main/java/org/joinmastodon/android/fragments/HomeTabFragment.java +++ b/mastodon/src/main/java/org/joinmastodon/android/fragments/HomeTabFragment.java @@ -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 fragments = new ArrayList<>(); - private final List 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 listItems = new HashMap<>(); private final Map hashtagsItems = new HashMap<>(); + private List timelineDefinitions; + private int count; + private Fragment[] fragments; + private FrameLayout[] tabViews; + private TimelineDefinition[] timelines; + private Map 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 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 void addItemsToMap(List addItems, Map 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 diff --git a/mastodon/src/main/java/org/joinmastodon/android/fragments/ListTimelineFragment.java b/mastodon/src/main/java/org/joinmastodon/android/fragments/ListTimelineFragment.java index 236636370..156123e02 100644 --- a/mastodon/src/main/java/org/joinmastodon/android/fragments/ListTimelineFragment.java +++ b/mastodon/src/main/java/org/joinmastodon/android/fragments/ListTimelineFragment.java @@ -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) diff --git a/mastodon/src/main/java/org/joinmastodon/android/fragments/ListTimelinesFragment.java b/mastodon/src/main/java/org/joinmastodon/android/fragments/ListTimelinesFragment.java index 2a22ae0d1..4eb83b807 100644 --- a/mastodon/src/main/java/org/joinmastodon/android/fragments/ListTimelinesFragment.java +++ b/mastodon/src/main/java/org/joinmastodon/android/fragments/ListTimelinesFragment.java @@ -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 im private HashMap 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 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 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); diff --git a/mastodon/src/main/java/org/joinmastodon/android/fragments/PinnableStatusListFragment.java b/mastodon/src/main/java/org/joinmastodon/android/fragments/PinnableStatusListFragment.java new file mode 100644 index 000000000..903736ce5 --- /dev/null +++ b/mastodon/src/main/java/org/joinmastodon/android/fragments/PinnableStatusListFragment.java @@ -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 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); + } + } +} diff --git a/mastodon/src/main/java/org/joinmastodon/android/fragments/SettingsFragment.java b/mastodon/src/main/java/org/joinmastodon/android/fragments/SettingsFragment.java index 3d6b1785f..a0630d5ab 100644 --- a/mastodon/src/main/java/org/joinmastodon/android/fragments/SettingsFragment.java +++ b/mastodon/src/main/java/org/joinmastodon/android/fragments/SettingsFragment.java @@ -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 diff --git a/mastodon/src/main/java/org/joinmastodon/android/model/TimelineDefinition.java b/mastodon/src/main/java/org/joinmastodon/android/model/TimelineDefinition.java new file mode 100644 index 000000000..1f3cc936d --- /dev/null +++ b/mastodon/src/main/java/org/joinmastodon/android/model/TimelineDefinition.java @@ -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 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 ALL_TIMELINES = List.of( + HOME_TIMELINE.copy(), + LOCAL_TIMELINE.copy(), + FEDERATED_TIMELINE.copy(), + POSTS_TIMELINE.copy() + ); +} diff --git a/mastodon/src/main/java/org/joinmastodon/android/ui/utils/UiUtils.java b/mastodon/src/main/java/org/joinmastodon/android/ui/utils/UiUtils.java index b58179bb8..e43b2aeeb 100644 --- a/mastodon/src/main/java/org/joinmastodon/android/ui/utils/UiUtils.java +++ b/mastodon/src/main/java/org/joinmastodon/android/ui/utils/UiUtils.java @@ -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 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 resultConsumer); diff --git a/mastodon/src/main/res/drawable/ic_fluent_add_24_regular.xml b/mastodon/src/main/res/drawable/ic_fluent_add_24_regular.xml new file mode 100644 index 000000000..04e974133 --- /dev/null +++ b/mastodon/src/main/res/drawable/ic_fluent_add_24_regular.xml @@ -0,0 +1,3 @@ + + + diff --git a/mastodon/src/main/res/drawable/ic_fluent_pin_24_filled.xml b/mastodon/src/main/res/drawable/ic_fluent_pin_24_filled.xml new file mode 100644 index 000000000..493dc05be --- /dev/null +++ b/mastodon/src/main/res/drawable/ic_fluent_pin_24_filled.xml @@ -0,0 +1,3 @@ + + + diff --git a/mastodon/src/main/res/drawable/ic_fluent_timeline_24_regular.xml b/mastodon/src/main/res/drawable/ic_fluent_timeline_24_regular.xml new file mode 100644 index 000000000..db79b17ac --- /dev/null +++ b/mastodon/src/main/res/drawable/ic_fluent_timeline_24_regular.xml @@ -0,0 +1,3 @@ + + + diff --git a/mastodon/src/main/res/layout/edit_timeline.xml b/mastodon/src/main/res/layout/edit_timeline.xml new file mode 100644 index 000000000..f2ee406b1 --- /dev/null +++ b/mastodon/src/main/res/layout/edit_timeline.xml @@ -0,0 +1,22 @@ + + + + + \ No newline at end of file diff --git a/mastodon/src/main/res/layout/item_text.xml b/mastodon/src/main/res/layout/item_text.xml index cf8ec9003..b73b5ace4 100644 --- a/mastodon/src/main/res/layout/item_text.xml +++ b/mastodon/src/main/res/layout/item_text.xml @@ -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" /> + + diff --git a/mastodon/src/main/res/menu/hashtag_timeline.xml b/mastodon/src/main/res/menu/hashtag_timeline.xml index 98d963ba8..efc7cc2e2 100644 --- a/mastodon/src/main/res/menu/hashtag_timeline.xml +++ b/mastodon/src/main/res/menu/hashtag_timeline.xml @@ -1,5 +1,10 @@ + + \ No newline at end of file diff --git a/mastodon/src/main/res/menu/home_switcher.xml b/mastodon/src/main/res/menu/home_switcher.xml deleted file mode 100644 index ccd7376a5..000000000 --- a/mastodon/src/main/res/menu/home_switcher.xml +++ /dev/null @@ -1,13 +0,0 @@ - - - - - - - - - - - - - \ No newline at end of file diff --git a/mastodon/src/main/res/menu/list.xml b/mastodon/src/main/res/menu/list.xml index 7693ee429..f2a85686e 100644 --- a/mastodon/src/main/res/menu/list.xml +++ b/mastodon/src/main/res/menu/list.xml @@ -1,13 +1,16 @@ + + android:icon="@drawable/ic_fluent_edit_24_regular" /> + android:icon="@drawable/ic_fluent_delete_24_regular" /> \ No newline at end of file diff --git a/mastodon/src/main/res/values/ids.xml b/mastodon/src/main/res/values/ids.xml index 36c9bd779..6994f851b 100644 --- a/mastodon/src/main/res/values/ids.xml +++ b/mastodon/src/main/res/values/ids.xml @@ -19,5 +19,6 @@ - + + \ No newline at end of file diff --git a/mastodon/src/main/res/values/strings_sk.xml b/mastodon/src/main/res/values/strings_sk.xml index 8fe7da5ac..c095a1842 100644 --- a/mastodon/src/main/res/values/strings_sk.xml +++ b/mastodon/src/main/res/values/strings_sk.xml @@ -154,4 +154,20 @@ Publish anyway Disable alt text reminder If you enable post notifications for some people, their new posts will appear here. + Timelines + Posts + Add + Timeline + List + Hashtag + Pin timeline + Unpin timeline + Pinned to home + Unpinned from home + Remove + Icon + Heart + Star + Edit timeline + Edit timelines \ No newline at end of file